Coverage for mcpgateway / admin.py: 99%

6317 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-18 12:49 +0000

1# -*- coding: utf-8 -*- 

2"""Location: ./mcpgateway/admin.py 

3Copyright 2025 

4SPDX-License-Identifier: Apache-2.0 

5Authors: Mihai Criveti 

6 

7Admin UI Routes for MCP Gateway. 

8This module contains all the administrative UI endpoints for the MCP Gateway. 

9It provides a comprehensive interface for managing servers, tools, resources, 

10prompts, gateways, and roots through RESTful API endpoints. The module handles 

11all aspects of CRUD operations for these entities, including creation, 

12reading, updating, deletion, and status toggling. 

13 

14All endpoints in this module require authentication, which is enforced via 

15the require_auth or require_basic_auth dependency. The module integrates with 

16various services to perform the actual business logic operations on the 

17underlying data. 

18""" 

19 

20# Standard 

21import asyncio 

22import binascii 

23from collections import defaultdict 

24import csv 

25from datetime import datetime, timedelta, timezone 

26from functools import wraps 

27import html 

28import io 

29import logging 

30import math 

31import os 

32from pathlib import Path 

33import re 

34import tempfile 

35import time 

36from typing import Any 

37from typing import cast as typing_cast 

38from typing import Dict, List, Optional, Union 

39import urllib.parse 

40import uuid 

41 

42# Third-Party 

43from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response 

44from fastapi.encoders import jsonable_encoder 

45from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse 

46from fastapi.security import HTTPAuthorizationCredentials 

47import httpx 

48import orjson 

49from pydantic import SecretStr, ValidationError 

50from pydantic_core import ValidationError as CoreValidationError 

51from sqlalchemy import and_, bindparam, case, cast, desc, false, func, or_, select, String, text 

52from sqlalchemy.exc import IntegrityError, InvalidRequestError, OperationalError 

53from sqlalchemy.orm import joinedload, selectinload, Session 

54from sqlalchemy.sql.functions import coalesce 

55from starlette.background import BackgroundTask 

56from starlette.datastructures import UploadFile as StarletteUploadFile 

57 

58# First-Party 

59from mcpgateway import __version__ 

60from mcpgateway import version as version_module 

61 

62# Authentication and password-related imports 

63from mcpgateway.auth import get_current_user, get_user_team_roles 

64from mcpgateway.cache.a2a_stats_cache import a2a_stats_cache 

65from mcpgateway.cache.global_config_cache import global_config_cache 

66from mcpgateway.common.models import LogLevel 

67from mcpgateway.common.validators import SecurityValidator 

68from mcpgateway.config import settings, UI_HIDABLE_HEADER_ITEMS, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES 

69from mcpgateway.db import A2AAgent as DbA2AAgent 

70from mcpgateway.db import EmailApiToken, EmailTeam, extract_json_field 

71from mcpgateway.db import Gateway as DbGateway 

72from mcpgateway.db import get_db, GlobalConfig, ObservabilitySavedQuery, ObservabilitySpan, ObservabilityTrace 

73from mcpgateway.db import Prompt as DbPrompt 

74from mcpgateway.db import Resource as DbResource 

75from mcpgateway.db import Server as DbServer 

76from mcpgateway.db import Tool as DbTool 

77from mcpgateway.db import utc_now 

78from mcpgateway.middleware.rbac import get_current_user_with_permissions, require_any_permission, require_permission 

79from mcpgateway.routers.email_auth import create_access_token 

80from mcpgateway.schemas import ( 

81 A2AAgentCreate, 

82 A2AAgentRead, 

83 A2AAgentUpdate, 

84 CatalogBulkRegisterRequest, 

85 CatalogBulkRegisterResponse, 

86 CatalogListRequest, 

87 CatalogListResponse, 

88 CatalogServerRegisterRequest, 

89 CatalogServerRegisterResponse, 

90 CatalogServerStatusResponse, 

91 GatewayCreate, 

92 GatewayRead, 

93 GatewayTestRequest, 

94 GatewayTestResponse, 

95 GatewayUpdate, 

96 GlobalConfigRead, 

97 GlobalConfigUpdate, 

98 PaginatedResponse, 

99 PaginationMeta, 

100 PluginDetail, 

101 PluginListResponse, 

102 PluginStatsResponse, 

103 PromptCreate, 

104 PromptMetrics, 

105 PromptRead, 

106 PromptUpdate, 

107 ResourceCreate, 

108 ResourceMetrics, 

109 ResourceUpdate, 

110 ServerCreate, 

111 ServerMetrics, 

112 ServerRead, 

113 ServerUpdate, 

114 ToolCreate, 

115 ToolMetrics, 

116 ToolRead, 

117 ToolUpdate, 

118) 

119from mcpgateway.services.a2a_service import A2AAgentError, A2AAgentNameConflictError, A2AAgentNotFoundError, A2AAgentService 

120from mcpgateway.services.argon2_service import Argon2PasswordService 

121from mcpgateway.services.audit_trail_service import get_audit_trail_service 

122from mcpgateway.services.catalog_service import catalog_service 

123from mcpgateway.services.email_auth_service import AuthenticationError, EmailAuthService, PasswordValidationError 

124from mcpgateway.services.encryption_service import get_encryption_service 

125from mcpgateway.services.export_service import ExportError, ExportService 

126from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayDuplicateConflictError, GatewayNameConflictError, GatewayNotFoundError, GatewayService 

127from mcpgateway.services.import_service import ConflictStrategy 

128from mcpgateway.services.import_service import ImportError as ImportServiceError 

129from mcpgateway.services.import_service import ImportService, ImportValidationError 

130from mcpgateway.services.logging_service import LoggingService 

131from mcpgateway.services.mcp_session_pool import get_mcp_session_pool 

132from mcpgateway.services.oauth_manager import OAuthManager 

133from mcpgateway.services.performance_service import get_performance_service 

134from mcpgateway.services.permission_service import PermissionService 

135from mcpgateway.services.plugin_service import get_plugin_service 

136from mcpgateway.services.prompt_service import PromptNameConflictError, PromptNotFoundError, PromptService 

137from mcpgateway.services.resource_service import ResourceNotFoundError, ResourceService, ResourceURIConflictError 

138from mcpgateway.services.root_service import RootService, RootServiceError, RootServiceNotFoundError 

139from mcpgateway.services.server_service import ServerError, ServerLockConflictError, ServerNameConflictError, ServerNotFoundError, ServerService 

140from mcpgateway.services.structured_logger import get_structured_logger 

141from mcpgateway.services.tag_service import TagService 

142from mcpgateway.services.team_management_service import TeamManagementService 

143from mcpgateway.services.token_catalog_service import TokenCatalogService 

144from mcpgateway.services.tool_service import ToolError, ToolLockConflictError, ToolNameConflictError, ToolNotFoundError, ToolService 

145from mcpgateway.utils.create_jwt_token import create_jwt_token, get_jwt_token 

146from mcpgateway.utils.error_formatter import ErrorFormatter 

147from mcpgateway.utils.metadata_capture import MetadataCapture 

148from mcpgateway.utils.orjson_response import ORJSONResponse 

149from mcpgateway.utils.pagination import paginate_query 

150from mcpgateway.utils.passthrough_headers import PassthroughHeadersError 

151from mcpgateway.utils.retry_manager import ResilientHttpClient 

152from mcpgateway.utils.security_cookies import clear_auth_cookie, CookieTooLargeError, set_auth_cookie 

153from mcpgateway.utils.services_auth import decode_auth 

154from mcpgateway.utils.sqlalchemy_modifier import json_contains_tag_expr 

155from mcpgateway.utils.validate_signature import sign_data 

156from mcpgateway.utils.verify_credentials import verify_jwt_token_cached 

157 

158# Conditional imports for gRPC support (only if grpcio is installed) 

159try: 

160 # First-Party 

161 from mcpgateway.schemas import GrpcServiceCreate, GrpcServiceRead, GrpcServiceUpdate 

162 from mcpgateway.services.grpc_service import GrpcService, GrpcServiceError, GrpcServiceNameConflictError, GrpcServiceNotFoundError 

163 

164 GRPC_AVAILABLE = True 

165except ImportError: 

166 GRPC_AVAILABLE = False 

167 # Define placeholder types to avoid NameError 

168 GrpcServiceCreate = None # type: ignore 

169 GrpcServiceRead = None # type: ignore 

170 GrpcServiceUpdate = None # type: ignore 

171 GrpcService = None # type: ignore 

172 

173 # Define placeholder exception classes that maintain the hierarchy 

174 class GrpcServiceError(Exception): # type: ignore 

175 """Placeholder for GrpcServiceError when grpcio is not installed.""" 

176 

177 class GrpcServiceNotFoundError(GrpcServiceError): # type: ignore 

178 """Placeholder for GrpcServiceNotFoundError when grpcio is not installed.""" 

179 

180 class GrpcServiceNameConflictError(GrpcServiceError): # type: ignore 

181 """Placeholder for GrpcServiceNameConflictError when grpcio is not installed.""" 

182 

183 

184# Import the shared logging service from main 

185# This will be set by main.py when it imports admin_router 

186logging_service: Optional[LoggingService] = None 

187LOGGER: logging.Logger = logging.getLogger("mcpgateway.admin") 

188 

189UI_SECTION_TO_TABS: Dict[str, tuple[str, ...]] = { 

190 "overview": ("overview",), 

191 "servers": ("catalog",), 

192 "gateways": ("gateways",), 

193 "tools": ("tools", "tool-ops"), 

194 "prompts": ("prompts",), 

195 "resources": ("resources",), 

196 "roots": ("roots",), 

197 "mcp-registry": ("mcp-registry",), 

198 "metrics": ("metrics",), 

199 "plugins": ("plugins",), 

200 "export-import": ("export-import",), 

201 "logs": ("logs",), 

202 "version-info": ("version-info",), 

203 "maintenance": ("maintenance",), 

204 "teams": ("teams",), 

205 "users": ("users",), 

206 "agents": ("a2a-agents", "grpc-services"), 

207 "tokens": ("tokens",), 

208 "settings": ("llm-settings",), 

209} 

210UI_EMBEDDED_DEFAULT_HIDDEN_HEADER_ITEMS: frozenset[str] = frozenset({"logout", "team_selector"}) 

211UI_HIDE_SECTIONS_COOKIE_NAME = "mcpgateway_ui_hide_sections" 

212UI_HIDE_SECTIONS_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days 

213 

214 

215def _normalize_ui_hide_values(raw: Any, valid_values: frozenset[str], aliases: Optional[Dict[str, str]] = None) -> set[str]: 

216 """Normalize UI hide values from CSV/list input into a validated set. 

217 

218 Args: 

219 raw: Source value (CSV string, iterable, or ``None``). 

220 valid_values: Allowed normalized values. 

221 aliases: Optional alias mapping to canonical values. 

222 

223 Returns: 

224 set[str]: Lowercase validated values with aliases resolved. 

225 """ 

226 if raw is None: 

227 return set() 

228 

229 tokens: list[str] = [] 

230 if isinstance(raw, str): 

231 tokens = [item.strip() for item in raw.split(",")] 

232 elif isinstance(raw, (list, tuple, set)): 

233 tokens = [str(item).strip() for item in raw] 

234 else: 

235 return set() 

236 

237 normalized: set[str] = set() 

238 for token in tokens: 

239 if not token: 

240 continue 

241 item = token.lower() 

242 if aliases: 

243 item = aliases.get(item, item) 

244 if item in valid_values: 

245 normalized.add(item) 

246 return normalized 

247 

248 

249def get_ui_visibility_config(request: Request) -> Dict[str, Any]: 

250 """Build final UI visibility settings for the current admin request. 

251 

252 Args: 

253 request: Incoming FastAPI request. 

254 

255 Returns: 

256 Dict[str, Any]: Hidden sections/header items/tabs plus cookie update intent. 

257 """ 

258 hidden_sections = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_sections", []), UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES) 

259 hidden_header_items = _normalize_ui_hide_values(getattr(settings, "mcpgateway_ui_hide_header_items", []), UI_HIDABLE_HEADER_ITEMS) 

260 

261 if bool(getattr(settings, "mcpgateway_ui_embedded", False)): 

262 hidden_header_items.update(UI_EMBEDDED_DEFAULT_HIDDEN_HEADER_ITEMS) 

263 

264 query_ui_hide_raw = request.query_params.get("ui_hide") 

265 query_ui_hide_values = _normalize_ui_hide_values(query_ui_hide_raw, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES) if query_ui_hide_raw is not None else set() 

266 

267 if query_ui_hide_raw is not None: 

268 # Query override is additive and also becomes the persisted session value. 

269 hidden_sections.update(query_ui_hide_values) 

270 else: 

271 cookie_ui_hide_raw = request.cookies.get(UI_HIDE_SECTIONS_COOKIE_NAME) 

272 hidden_sections.update(_normalize_ui_hide_values(cookie_ui_hide_raw, UI_HIDABLE_SECTIONS, UI_HIDE_SECTION_ALIASES)) 

273 

274 hidden_tabs: set[str] = set() 

275 for section in hidden_sections: 

276 hidden_tabs.update(UI_SECTION_TO_TABS.get(section, ())) 

277 

278 cookie_action: Optional[str] = None 

279 cookie_value: Optional[str] = None 

280 if query_ui_hide_raw is not None: 

281 # Empty query clears persisted overrides. 

282 if query_ui_hide_values: 

283 cookie_action = "set" 

284 cookie_value = ",".join(sorted(query_ui_hide_values)) 

285 else: 

286 cookie_action = "delete" 

287 

288 return { 

289 "hidden_sections": sorted(hidden_sections), 

290 "hidden_header_items": sorted(hidden_header_items), 

291 "hidden_tabs": sorted(hidden_tabs), 

292 "cookie_action": cookie_action, 

293 "cookie_value": cookie_value, 

294 } 

295 

296 

297def set_logging_service(service: LoggingService): 

298 """Set the logging service instance to use. 

299 

300 This should be called by main.py to share the same logging service. 

301 

302 Args: 

303 service: The LoggingService instance to use 

304 

305 Examples: 

306 >>> from mcpgateway.services.logging_service import LoggingService 

307 >>> from mcpgateway import admin 

308 >>> logging_svc = LoggingService() 

309 >>> admin.set_logging_service(logging_svc) 

310 >>> admin.logging_service is not None 

311 True 

312 >>> admin.LOGGER is not None 

313 True 

314 

315 Test with different service instance: 

316 >>> new_svc = LoggingService() 

317 >>> admin.set_logging_service(new_svc) 

318 >>> admin.logging_service == new_svc 

319 True 

320 >>> admin.LOGGER.name 

321 'mcpgateway.admin' 

322 

323 Test that global variables are properly set: 

324 >>> admin.set_logging_service(logging_svc) 

325 >>> hasattr(admin, 'logging_service') 

326 True 

327 >>> hasattr(admin, 'LOGGER') 

328 True 

329 """ 

330 global logging_service, LOGGER # pylint: disable=global-statement 

331 logging_service = service 

332 LOGGER = logging_service.get_logger("mcpgateway.admin") 

333 

334 

335# Fallback for testing - create a temporary instance if not set 

336if logging_service is None: 

337 logging_service = LoggingService() 

338 LOGGER = logging_service.get_logger("mcpgateway.admin") 

339 

340 

341# Initialize services 

342server_service: ServerService = ServerService() 

343tool_service: ToolService = ToolService() 

344prompt_service: PromptService = PromptService() 

345gateway_service: GatewayService = GatewayService() 

346resource_service: ResourceService = ResourceService() 

347root_service: RootService = RootService() 

348export_service: ExportService = ExportService() 

349import_service: ImportService = ImportService() 

350# Initialize A2A service only if A2A features are enabled 

351a2a_service: Optional[A2AAgentService] = A2AAgentService() if settings.mcpgateway_a2a_enabled else None 

352# Initialize gRPC service only if gRPC features are enabled AND grpcio is installed 

353grpc_service_mgr: Optional[Any] = GrpcService() if (settings.mcpgateway_grpc_enabled and GRPC_AVAILABLE and GrpcService is not None) else None 

354 

355# Set up basic authentication 

356 

357# Rate limiting storage 

358rate_limit_storage = defaultdict(list) 

359 

360 

361def _normalize_team_id(team_id: Optional[str]) -> Optional[str]: 

362 """Validate and normalize team IDs for UI endpoints. 

363 

364 Args: 

365 team_id: Raw team ID from request params. 

366 

367 Returns: 

368 Normalized team ID string or None. 

369 

370 Raises: 

371 ValueError: If the team ID is not a valid UUID. 

372 """ 

373 if not team_id: 

374 return None 

375 try: 

376 return uuid.UUID(str(team_id)).hex 

377 except (ValueError, AttributeError, TypeError) as exc: 

378 raise ValueError("Invalid team ID") from exc 

379 

380 

381def _validated_team_id_param(team_id: Optional[str] = Query(None, description="Filter by team ID")) -> Optional[str]: 

382 """Normalize team ID query params and raise on invalid UUIDs. 

383 

384 Args: 

385 team_id: Raw team ID from query params. 

386 

387 Returns: 

388 Normalized team ID string or None. 

389 

390 Raises: 

391 HTTPException: If the team ID is not a valid UUID. 

392 """ 

393 try: 

394 return _normalize_team_id(team_id) 

395 except ValueError as exc: 

396 raise HTTPException(status_code=400, detail="Invalid team ID") from exc 

397 

398 

399def get_client_ip(request: Request) -> str: 

400 """Extract client IP address from request. 

401 

402 Args: 

403 request: FastAPI request object 

404 

405 Returns: 

406 str: Client IP address 

407 

408 Examples: 

409 >>> from unittest.mock import MagicMock 

410 >>> 

411 >>> # Test with X-Forwarded-For header 

412 >>> mock_request = MagicMock() 

413 >>> mock_request.headers = {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"} 

414 >>> get_client_ip(mock_request) 

415 '192.168.1.1' 

416 >>> 

417 >>> # Test with X-Real-IP header 

418 >>> mock_request.headers = {"X-Real-IP": "10.0.0.5"} 

419 >>> get_client_ip(mock_request) 

420 '10.0.0.5' 

421 >>> 

422 >>> # Test with direct client IP 

423 >>> mock_request.headers = {} 

424 >>> mock_request.client.host = "127.0.0.1" 

425 >>> get_client_ip(mock_request) 

426 '127.0.0.1' 

427 >>> 

428 >>> # Test with no client info 

429 >>> mock_request.client = None 

430 >>> get_client_ip(mock_request) 

431 'unknown' 

432 """ 

433 # Check for X-Forwarded-For header (proxy/load balancer) 

434 forwarded_for = request.headers.get("X-Forwarded-For") 

435 if forwarded_for: 

436 return forwarded_for.split(",")[0].strip() 

437 

438 # Check for X-Real-IP header 

439 real_ip = request.headers.get("X-Real-IP") 

440 if real_ip: 

441 return real_ip 

442 

443 # Fall back to direct client IP 

444 return request.client.host if request.client else "unknown" 

445 

446 

447def get_user_agent(request: Request) -> str: 

448 """Extract user agent from request. 

449 

450 Args: 

451 request: FastAPI request object 

452 

453 Returns: 

454 str: User agent string 

455 

456 Examples: 

457 >>> from unittest.mock import MagicMock 

458 >>> 

459 >>> # Test with User-Agent header 

460 >>> mock_request = MagicMock() 

461 >>> mock_request.headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0)"} 

462 >>> get_user_agent(mock_request) 

463 'Mozilla/5.0 (Windows NT 10.0)' 

464 >>> 

465 >>> # Test without User-Agent header 

466 >>> mock_request.headers = {} 

467 >>> get_user_agent(mock_request) 

468 'unknown' 

469 """ 

470 return request.headers.get("User-Agent", "unknown") 

471 

472 

473def rate_limit(requests_per_minute: Optional[int] = None): 

474 """Apply rate limiting to admin endpoints. 

475 

476 Args: 

477 requests_per_minute: Maximum requests per minute (uses config default if None) 

478 

479 Returns: 

480 Decorator function that enforces rate limiting 

481 

482 Examples: 

483 Test basic decorator creation: 

484 >>> from mcpgateway import admin 

485 >>> decorator = admin.rate_limit(10) 

486 >>> callable(decorator) 

487 True 

488 

489 Test with None parameter (uses default): 

490 >>> default_decorator = admin.rate_limit(None) 

491 >>> callable(default_decorator) 

492 True 

493 

494 Test with specific limit: 

495 >>> limited_decorator = admin.rate_limit(5) 

496 >>> callable(limited_decorator) 

497 True 

498 

499 Test decorator returns wrapper: 

500 >>> async def dummy_func(): 

501 ... return "success" 

502 >>> decorated_func = decorator(dummy_func) 

503 >>> callable(decorated_func) 

504 True 

505 

506 Test rate limit storage structure: 

507 >>> isinstance(admin.rate_limit_storage, dict) 

508 True 

509 >>> from collections import defaultdict 

510 >>> isinstance(admin.rate_limit_storage, defaultdict) 

511 True 

512 

513 Test decorator with zero limit: 

514 >>> zero_limit_decorator = admin.rate_limit(0) 

515 >>> callable(zero_limit_decorator) 

516 True 

517 

518 Test decorator with high limit: 

519 >>> high_limit_decorator = admin.rate_limit(1000) 

520 >>> callable(high_limit_decorator) 

521 True 

522 """ 

523 

524 def decorator(func_to_wrap): 

525 """Decorator that wraps the function with rate limiting logic. 

526 

527 Args: 

528 func_to_wrap: The function to be wrapped with rate limiting 

529 

530 Returns: 

531 The wrapped function with rate limiting applied 

532 """ 

533 

534 @wraps(func_to_wrap) 

535 async def wrapper(*args, request: Optional[Request] = None, **kwargs): 

536 """Execute the wrapped function with rate limiting enforcement. 

537 

538 Args: 

539 *args: Positional arguments to pass to the wrapped function 

540 request: FastAPI Request object for extracting client IP 

541 **kwargs: Keyword arguments to pass to the wrapped function 

542 

543 Returns: 

544 The result of the wrapped function call 

545 

546 Raises: 

547 HTTPException: When rate limit is exceeded (429 status) 

548 """ 

549 # use configured limit if none provided 

550 limit = requests_per_minute or settings.validation_max_requests_per_minute 

551 

552 # request can be None in some edge cases (e.g., tests) 

553 client_ip = request.client.host if request and request.client else "unknown" 

554 current_time = time.time() 

555 minute_ago = current_time - 60 

556 

557 # prune old timestamps 

558 rate_limit_storage[client_ip] = [ts for ts in rate_limit_storage[client_ip] if ts > minute_ago] 

559 

560 # enforce 

561 if len(rate_limit_storage[client_ip]) >= limit: 

562 LOGGER.warning(f"Rate limit exceeded for IP {client_ip} on endpoint {func_to_wrap.__name__}") 

563 raise HTTPException( 

564 status_code=429, 

565 detail=f"Rate limit exceeded. Maximum {limit} requests per minute.", 

566 ) 

567 rate_limit_storage[client_ip].append(current_time) 

568 # IMPORTANT: forward request to the real endpoint 

569 return await func_to_wrap(*args, request=request, **kwargs) 

570 

571 return wrapper 

572 

573 return decorator 

574 

575 

576def get_user_email(user: Union[str, dict, object] = None) -> str: 

577 """Return the user email from a JWT payload, user object, or string. 

578 

579 Args: 

580 user (Union[str, dict, object], optional): User object from JWT token 

581 (from get_current_user_with_permissions). Can be: 

582 - dict: representing JWT payload 

583 - object: with an `email` attribute 

584 - str: an email string 

585 - None: will return "unknown" 

586 Defaults to None. 

587 

588 Returns: 

589 str: User email address, or "unknown" if no email can be determined. 

590 - If `user` is a dict, returns `sub` if present, else `email`, else "unknown". 

591 - If `user` has an `email` attribute, returns that. 

592 - If `user` is a string, returns it. 

593 - If `user` is None, returns "unknown". 

594 - Otherwise, returns str(user). 

595 

596 Examples: 

597 >>> get_user_email({'sub': 'alice@example.com'}) 

598 'alice@example.com' 

599 >>> get_user_email({'email': 'bob@company.com'}) 

600 'bob@company.com' 

601 >>> get_user_email({'sub': 'charlie@primary.com', 'email': 'charlie@secondary.com'}) 

602 'charlie@primary.com' 

603 >>> get_user_email({'username': 'dave'}) 

604 'unknown' 

605 >>> class MockUser: 

606 ... def __init__(self, email): 

607 ... self.email = email 

608 >>> get_user_email(MockUser('eve@test.com')) 

609 'eve@test.com' 

610 >>> get_user_email(None) 

611 'unknown' 

612 >>> get_user_email('grace@example.org') 

613 'grace@example.org' 

614 >>> get_user_email({}) 

615 'unknown' 

616 >>> get_user_email(12345) 

617 '12345' 

618 """ 

619 if isinstance(user, dict): 

620 return user.get("sub") or user.get("email") or "unknown" 

621 

622 if hasattr(user, "email"): 

623 return user.email 

624 

625 if user is None: 

626 return "unknown" 

627 

628 return str(user) 

629 

630 

631def _get_user_team_roles(db: Session, user_email: str) -> Dict[str, str]: 

632 """Return a {team_id: role} mapping for a user's active memberships. 

633 

634 Args: 

635 db: The SQLAlchemy database session. 

636 user_email: Email address of the user to query memberships for. 

637 

638 Returns: 

639 Dict mapping team_id to the user's role in that team. 

640 """ 

641 return get_user_team_roles(db, user_email) 

642 

643 

644def _adjust_pagination_for_conversion_failures(pagination: "PaginationMeta", failed_count: int) -> None: 

645 """Adjust pagination metadata to account for DB-to-Pydantic conversion failures. 

646 

647 When items on the current page fail to convert, the "Showing X of Y" display 

648 would otherwise count items that aren't actually displayed. This adjusts 

649 total_items and recomputes derived fields (total_pages, has_next, has_prev). 

650 

651 Args: 

652 pagination: The PaginationMeta object to adjust (modified in-place). 

653 failed_count: Number of items that failed conversion on the current page. 

654 """ 

655 if failed_count > 0: 

656 pagination.total_items = max(0, pagination.total_items - failed_count) 

657 pagination.total_pages = math.ceil(pagination.total_items / pagination.per_page) if pagination.total_items > 0 else 0 

658 # Do NOT clamp pagination.page — data was already fetched for this page, 

659 # so the page number must match the displayed data. 

660 pagination.has_next = pagination.page < pagination.total_pages 

661 pagination.has_prev = pagination.page > 1 

662 

663 

664def _get_span_entity_performance( 

665 db: Session, 

666 cutoff_time: datetime, 

667 cutoff_time_naive: datetime, 

668 span_names: List[str], 

669 json_key: str, 

670 result_key: str, 

671 limit: int = 20, 

672) -> List[dict]: 

673 """Shared helper to compute performance metrics for spans grouped by a JSON attribute. 

674 

675 Args: 

676 db: Database session. 

677 cutoff_time: Timezone-aware datetime for filtering spans. 

678 cutoff_time_naive: Naive datetime for SQLite compatibility. 

679 span_names: List of span names to filter (e.g., ["tool.invoke"]). 

680 json_key: JSON attribute key to group by (e.g., "tool.name"). 

681 result_key: Key name for the entity in returned dicts (e.g., "tool_name"). 

682 limit: Maximum number of results to return (default: 20). 

683 

684 Returns: 

685 List[dict]: List of dicts with entity key and performance metrics (count, avg, min, max, percentiles). 

686 

687 Raises: 

688 ValueError: If `json_key` is not a valid identifier (only letters, digits, underscore, dot or hyphen), 

689 this function will raise a ValueError to prevent unsafe SQL interpolation when using 

690 PostgreSQL native percentile queries. 

691 

692 Note: 

693 Uses PostgreSQL `percentile_cont` when available and enabled via USE_POSTGRESDB_PERCENTILES config, 

694 otherwise falls back to Python aggregation. 

695 """ 

696 # Validate json_key to prevent SQL injection in both PostgreSQL and SQLite paths 

697 if not isinstance(json_key, str) or not re.match(r"^[A-Za-z0-9_.-]+$", json_key): 

698 raise ValueError("Invalid json_key for percentile query") 

699 

700 dialect_name = db.get_bind().dialect.name 

701 

702 # Use database-native percentiles only if enabled in config and using PostgreSQL 

703 if dialect_name == "postgresql" and settings.use_postgresdb_percentiles: 

704 # Safe: uses SQLAlchemy's bindparam for the IN-list 

705 stats_sql = text( 

706 """ 

707 SELECT 

708 (attributes->> :json_key) AS entity, 

709 COUNT(*) AS count, 

710 AVG(duration_ms) AS avg_duration_ms, 

711 MIN(duration_ms) AS min_duration_ms, 

712 MAX(duration_ms) AS max_duration_ms, 

713 percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) AS p50, 

714 percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) AS p90, 

715 percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95, 

716 percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99 

717 FROM observability_spans 

718 WHERE name IN :names 

719 AND start_time >= :cutoff_time 

720 AND duration_ms IS NOT NULL 

721 AND (attributes->> :json_key) IS NOT NULL 

722 GROUP BY entity 

723 ORDER BY avg_duration_ms DESC 

724 LIMIT :limit 

725 """ 

726 ).bindparams(bindparam("names", expanding=True)) 

727 

728 results = db.execute( 

729 stats_sql, 

730 {"cutoff_time": cutoff_time, "limit": limit, "names": span_names, "json_key": json_key}, 

731 ).fetchall() 

732 

733 items: List[dict] = [] 

734 for row in results: 

735 items.append( 

736 { 

737 result_key: row.entity, 

738 "count": int(row.count) if row.count is not None else 0, 

739 "avg_duration_ms": round(float(row.avg_duration_ms), 2) if row.avg_duration_ms is not None else 0, 

740 "min_duration_ms": round(float(row.min_duration_ms), 2) if row.min_duration_ms is not None else 0, 

741 "max_duration_ms": round(float(row.max_duration_ms), 2) if row.max_duration_ms is not None else 0, 

742 "p50": round(float(row.p50), 2) if row.p50 is not None else 0, 

743 "p90": round(float(row.p90), 2) if row.p90 is not None else 0, 

744 "p95": round(float(row.p95), 2) if row.p95 is not None else 0, 

745 "p99": round(float(row.p99), 2) if row.p99 is not None else 0, 

746 } 

747 ) 

748 

749 return items 

750 

751 # Fallback: Python aggregation (SQLite or other DBs, or PostgreSQL with USE_POSTGRESDB_PERCENTILES=False) 

752 # Pass dialect_name to extract_json_field to ensure correct SQL syntax for the actual database 

753 # Use timezone-aware cutoff for PostgreSQL to avoid timezone drift, naive for SQLite 

754 effective_cutoff = cutoff_time if dialect_name == "postgresql" else cutoff_time_naive 

755 spans = ( 

756 db.query( 

757 extract_json_field(ObservabilitySpan.attributes, f'$."{json_key}"', dialect_name=dialect_name).label("entity"), 

758 ObservabilitySpan.duration_ms, 

759 ) 

760 .filter( 

761 ObservabilitySpan.name.in_(span_names), 

762 ObservabilitySpan.start_time >= effective_cutoff, 

763 ObservabilitySpan.duration_ms.isnot(None), 

764 extract_json_field(ObservabilitySpan.attributes, f'$."{json_key}"', dialect_name=dialect_name).isnot(None), 

765 ) 

766 .all() 

767 ) 

768 

769 durations_by_entity: Dict[str, List[float]] = defaultdict(list) 

770 for span in spans: 

771 durations_by_entity[span.entity].append(span.duration_ms) 

772 

773 def percentile(data: List[float], p: float) -> float: 

774 """Calculate percentile using linear interpolation (matches PostgreSQL percentile_cont). 

775 

776 Args: 

777 data: Sorted, non-empty list of numeric values. 

778 p: Percentile to calculate (0.0 to 1.0). 

779 

780 Returns: 

781 float: The interpolated percentile value. 

782 """ 

783 n = len(data) 

784 k = p * (n - 1) 

785 f = int(k) 

786 c = k - f 

787 next_i = min(f + 1, n - 1) 

788 return data[f] + c * (data[next_i] - data[f]) 

789 

790 items: List[dict] = [] 

791 for entity, durations in durations_by_entity.items(): 

792 durations_sorted = sorted(durations) 

793 n = len(durations_sorted) 

794 items.append( 

795 { 

796 result_key: entity, 

797 "count": n, 

798 "avg_duration_ms": round(sum(durations) / n, 2), 

799 "min_duration_ms": round(min(durations), 2), 

800 "max_duration_ms": round(max(durations), 2), 

801 "p50": round(percentile(durations_sorted, 0.50), 2), 

802 "p90": round(percentile(durations_sorted, 0.90), 2), 

803 "p95": round(percentile(durations_sorted, 0.95), 2), 

804 "p99": round(percentile(durations_sorted, 0.99), 2), 

805 } 

806 ) 

807 

808 items.sort(key=lambda x: x.get("avg_duration_ms", 0), reverse=True) 

809 return items[:limit] 

810 

811 

812def get_user_id(user: Union[str, dict[str, Any], object] = None) -> str: 

813 """Return the user ID from a JWT payload, user object, or string. 

814 

815 Args: 

816 user (Union[str, dict, object], optional): User object from JWT token 

817 (from get_current_user_with_permissions). Can be: 

818 - dict: representing JWT payload with 'id', 'user_id', or 'sub' 

819 - object: with an `id` attribute 

820 - str: a user ID string 

821 - None: will return "unknown" 

822 Defaults to None. 

823 

824 Returns: 

825 str: User ID, or "unknown" if no ID can be determined. 

826 - If `user` is a dict, returns `id` if present, else `user_id`, else `sub`, else email as fallback, else "unknown". 

827 - If `user` has an `id` attribute, returns that. 

828 - If `user` is a string, returns it. 

829 - If `user` is None, returns "unknown". 

830 - Otherwise, returns str(user). 

831 

832 Examples: 

833 >>> get_user_id({'id': '123'}) 

834 '123' 

835 >>> get_user_id({'user_id': '456'}) 

836 '456' 

837 >>> get_user_id({'sub': 'alice@example.com'}) 

838 'alice@example.com' 

839 >>> get_user_id({'email': 'bob@company.com'}) 

840 'bob@company.com' 

841 >>> class MockUser: 

842 ... def __init__(self, user_id): 

843 ... self.id = user_id 

844 >>> get_user_id(MockUser('789')) 

845 '789' 

846 >>> get_user_id(None) 

847 'unknown' 

848 >>> get_user_id('user-xyz') 

849 'user-xyz' 

850 >>> get_user_id({}) 

851 'unknown' 

852 """ 

853 if isinstance(user, dict): 

854 # Try multiple possible ID fields in order of preference. 

855 # Email is the primary key in the model, so that's our mostly likely result. 

856 return user.get("id") or user.get("user_id") or user.get("sub") or user.get("email") or "unknown" 

857 

858 return "unknown" if user is None else str(getattr(user, "id", user)) 

859 

860 

861def serialize_datetime(obj): 

862 """Convert datetime objects to ISO format strings for JSON serialization. 

863 

864 Args: 

865 obj: Object to serialize, potentially a datetime 

866 

867 Returns: 

868 str: ISO format string if obj is datetime, otherwise returns obj unchanged 

869 

870 Examples: 

871 Test with datetime object: 

872 >>> from mcpgateway import admin 

873 >>> from datetime import datetime, timezone 

874 >>> dt = datetime(2025, 1, 15, 10, 30, 45, tzinfo=timezone.utc) 

875 >>> admin.serialize_datetime(dt) 

876 '2025-01-15T10:30:45+00:00' 

877 

878 Test with naive datetime: 

879 >>> dt_naive = datetime(2025, 3, 20, 14, 15, 30) 

880 >>> result = admin.serialize_datetime(dt_naive) 

881 >>> '2025-03-20T14:15:30' in result 

882 True 

883 

884 Test with datetime with microseconds: 

885 >>> dt_micro = datetime(2025, 6, 10, 9, 25, 12, 500000) 

886 >>> result = admin.serialize_datetime(dt_micro) 

887 >>> '2025-06-10T09:25:12.500000' in result 

888 True 

889 

890 Test with non-datetime objects (should return unchanged): 

891 >>> admin.serialize_datetime("2025-01-15T10:30:45") 

892 '2025-01-15T10:30:45' 

893 >>> admin.serialize_datetime(12345) 

894 12345 

895 >>> admin.serialize_datetime(['a', 'list']) 

896 ['a', 'list'] 

897 >>> admin.serialize_datetime({'key': 'value'}) 

898 {'key': 'value'} 

899 >>> admin.serialize_datetime(None) 

900 >>> admin.serialize_datetime(True) 

901 True 

902 

903 Test with current datetime: 

904 >>> import datetime as dt_module 

905 >>> now = dt_module.datetime.now() 

906 >>> result = admin.serialize_datetime(now) 

907 >>> isinstance(result, str) 

908 True 

909 >>> 'T' in result # ISO format contains 'T' separator 

910 True 

911 

912 Test edge case with datetime min/max: 

913 >>> dt_min = datetime.min 

914 >>> result = admin.serialize_datetime(dt_min) 

915 >>> result.startswith('0001-01-01T') 

916 True 

917 """ 

918 if isinstance(obj, datetime): 

919 return obj.isoformat() 

920 return obj 

921 

922 

923def validate_password_strength(password: str) -> tuple[bool, str]: 

924 """Validate password meets strength requirements. 

925 

926 Uses configurable settings from config.py for password policy. 

927 Respects password_policy_enabled toggle - if disabled, all passwords pass. 

928 

929 Args: 

930 password: Password to validate 

931 

932 Returns: 

933 tuple: (is_valid, error_message) 

934 """ 

935 # If password policy is disabled, skip all validation 

936 if not getattr(settings, "password_policy_enabled", True): 

937 return True, "" 

938 

939 min_length = getattr(settings, "password_min_length", 8) 

940 require_uppercase = getattr(settings, "password_require_uppercase", False) 

941 require_lowercase = getattr(settings, "password_require_lowercase", False) 

942 require_numbers = getattr(settings, "password_require_numbers", False) 

943 require_special = getattr(settings, "password_require_special", False) 

944 

945 if len(password) < min_length: 

946 return False, f"Password must be at least {min_length} characters long" 

947 

948 if require_uppercase and not any(c.isupper() for c in password): 

949 return False, "Password must contain at least one uppercase letter (A-Z)" 

950 

951 if require_lowercase and not any(c.islower() for c in password): 

952 return False, "Password must contain at least one lowercase letter (a-z)" 

953 

954 if require_numbers and not any(c.isdigit() for c in password): 

955 return False, "Password must contain at least one number (0-9)" 

956 

957 # Match the special character set used in EmailAuthService 

958 special_chars = '!@#$%^&*(),.?":{}|<>' 

959 if require_special and not any(c in special_chars for c in password): 

960 return False, f"Password must contain at least one special character ({special_chars})" 

961 

962 return True, "" 

963 

964 

965admin_router = APIRouter(prefix="/admin", tags=["Admin UI"]) 

966 

967#################### 

968# Admin UI Routes # 

969#################### 

970 

971 

972def _escape_like(value: str) -> str: 

973 """Escape SQL LIKE wildcard characters. 

974 

975 Args: 

976 value (str): Raw search string. 

977 

978 Returns: 

979 str: Escaped string safe for use in ``LIKE`` expressions with ``ESCAPE '\\'``. 

980 """ 

981 return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") 

982 

983 

984def _like_contains(column, value: str): 

985 """Case-insensitive substring match with proper LIKE wildcard escaping. 

986 

987 Wraps the escaped *value* with ``%`` wildcards and adds an explicit 

988 ``ESCAPE '\\\\'`` clause so that ``%`` and ``_`` in the search term are 

989 treated literally on all backends (SQLite requires the clause). 

990 

991 Args: 

992 column: SQLAlchemy column expression (pre-wrapped with ``func.lower`` 

993 / ``coalesce`` as needed by the caller). 

994 value: Raw search term — escaping is applied internally. 

995 

996 Returns: 

997 A SQLAlchemy binary expression suitable for ``.where()``. 

998 """ 

999 return column.like("%" + _escape_like(value) + "%", escape="\\") 

1000 

1001 

1002async def _get_user_team_ids(user: dict, db: Session) -> list: 

1003 """Return team IDs for the authenticated user. 

1004 

1005 When called from :func:`admin_unified_search`, the user dict carries a 

1006 ``_cached_team_ids`` key so the expensive lookup is executed only once 

1007 per request instead of once per entity type. 

1008 

1009 If the auth context includes explicit ``token_teams`` (API tokens), the 

1010 returned IDs are derived from that token scope so search endpoints cannot 

1011 return entities outside the token's team restrictions. 

1012 

1013 Args: 

1014 user (dict): Authenticated user context. 

1015 db (Session): Database session. 

1016 

1017 Returns: 

1018 list: Team ID list for the user. 

1019 """ 

1020 cached = user.get("_cached_team_ids") 

1021 if cached is not None: 

1022 return cached 

1023 

1024 if "token_teams" in user: 

1025 token_teams = user.get("token_teams") 

1026 if token_teams is not None: 

1027 team_ids: list[str] = [] 

1028 for team in token_teams: 

1029 if isinstance(team, dict): 

1030 team_id = team.get("id") 

1031 if isinstance(team_id, str) and team_id: 

1032 team_ids.append(team_id) 

1033 elif isinstance(team, str) and team: 

1034 team_ids.append(team) 

1035 return team_ids 

1036 

1037 user_email = get_user_email(user) 

1038 team_service = TeamManagementService(db) 

1039 user_teams = await team_service.get_user_teams(user_email) 

1040 return [t.id for t in user_teams] 

1041 

1042 

1043def _is_explicit_token_team_scope(user: Any) -> bool: 

1044 """Return whether the auth context carries explicit token team scope. 

1045 

1046 Tokens with ``token_teams`` present and not ``None`` are scope-constrained 

1047 (including public-only tokens with ``[]``). ``None`` denotes admin bypass. 

1048 

1049 Args: 

1050 user (Any): Authenticated user context. 

1051 

1052 Returns: 

1053 bool: True when ``token_teams`` is present and not ``None``. 

1054 """ 

1055 if not isinstance(user, dict): 

1056 return False 

1057 return "token_teams" in user and user.get("token_teams") is not None 

1058 

1059 

1060def _owner_access_condition(owner_column, team_column, *, user_email: str, team_ids: list[str], user: Any): 

1061 """Build owner visibility predicate honoring token team scoping. 

1062 

1063 For explicit token scopes, owner visibility is constrained to token teams. 

1064 For legacy/session contexts without explicit scope (or admin bypass), keep 

1065 existing owner visibility semantics. 

1066 

1067 Args: 

1068 owner_column: SQLAlchemy owner-email column expression. 

1069 team_column: SQLAlchemy team-id column expression. 

1070 user_email (str): Current user email. 

1071 team_ids (list[str]): Team IDs visible to this auth context. 

1072 user (Any): Authenticated user context. 

1073 

1074 Returns: 

1075 Any: SQLAlchemy boolean predicate for owner visibility. 

1076 """ 

1077 if _is_explicit_token_team_scope(user): 

1078 if not team_ids: 

1079 return false() 

1080 return and_(owner_column == user_email, team_column.in_(team_ids)) 

1081 return owner_column == user_email 

1082 

1083 

1084async def _has_permission( 

1085 *, 

1086 db: Session, 

1087 user: dict, 

1088 permission: str, 

1089 team_id: Optional[str] = None, 

1090 allow_admin_bypass: bool = False, 

1091 check_any_team: bool = False, 

1092) -> bool: 

1093 """Check a permission for the current user context. 

1094 

1095 Args: 

1096 db (Session): Database session. 

1097 user (dict): Authenticated user context. 

1098 permission (str): Permission to evaluate. 

1099 team_id (Optional[str]): Optional team scope for the permission check. 

1100 allow_admin_bypass (bool): Whether admin bypass is allowed. 

1101 check_any_team (bool): Whether to check across all team-scoped roles. 

1102 

1103 Returns: 

1104 bool: True when permission is granted. 

1105 """ 

1106 permission_service = PermissionService(db) 

1107 return await permission_service.check_permission( 

1108 user_email=get_user_email(user), 

1109 permission=permission, 

1110 team_id=team_id, 

1111 ip_address=user.get("ip_address"), 

1112 user_agent=user.get("user_agent"), 

1113 allow_admin_bypass=allow_admin_bypass, 

1114 check_any_team=check_any_team, 

1115 ) 

1116 

1117 

1118def _normalize_search_query(query: Optional[str]) -> str: 

1119 """Normalize search query values for consistent filtering. 

1120 

1121 Args: 

1122 query (Optional[str]): Raw query value or FastAPI ``Query`` wrapper. 

1123 

1124 Returns: 

1125 str: Lowercased, trimmed query string (empty string when unset). 

1126 """ 

1127 if query is None: 

1128 return "" 

1129 if isinstance(query, str): 

1130 return query.strip().lower() 

1131 

1132 # Support direct unit-test invocation where FastAPI Query(...) defaults 

1133 # can be passed through instead of resolved string values. 

1134 default_value = getattr(query, "default", None) 

1135 if default_value is None: 

1136 return "" 

1137 if isinstance(default_value, str): 

1138 return default_value.strip().lower() 

1139 return str(default_value).strip().lower() 

1140 

1141 

1142def _normalize_tags_query(tags: Any) -> str: 

1143 """Normalize tags query values. 

1144 

1145 Handles plain strings and FastAPI `Query(...)` defaults when handlers are 

1146 called directly in unit tests. 

1147 

1148 Args: 

1149 tags (Any): Raw tags value or FastAPI ``Query`` wrapper. 

1150 

1151 Returns: 

1152 str: Trimmed tags expression (empty string when unset). 

1153 """ 

1154 if tags is None: 

1155 return "" 

1156 if isinstance(tags, str): 

1157 return tags.strip() 

1158 

1159 default_value = getattr(tags, "default", None) 

1160 if default_value is None: 

1161 return "" 

1162 if isinstance(default_value, str): 

1163 return default_value.strip() 

1164 return str(default_value).strip() 

1165 

1166 

1167def _normalize_int_query(value: Any, fallback: int) -> int: 

1168 """Normalize integer query values, including FastAPI Query defaults. 

1169 

1170 Args: 

1171 value (Any): Raw integer value or FastAPI ``Query`` wrapper. 

1172 fallback (int): Fallback value when normalization fails. 

1173 

1174 Returns: 

1175 int: Normalized integer value. 

1176 """ 

1177 if isinstance(value, int): 

1178 return value 

1179 

1180 default_value = getattr(value, "default", None) 

1181 if isinstance(default_value, int): 

1182 return default_value 

1183 

1184 try: 

1185 return int(value) 

1186 except (TypeError, ValueError): 

1187 return fallback 

1188 

1189 

1190_TAG_MAX_GROUPS = 20 

1191_TAG_MAX_TERMS_PER_GROUP = 10 

1192 

1193 

1194def _parse_tag_filter_groups(tags: Optional[str]) -> list[list[str]]: 

1195 """Parse tag filter expressions. 

1196 

1197 Expression syntax: 

1198 - `,` separates OR groups (capped at :data:`_TAG_MAX_GROUPS`) 

1199 - `+` separates AND terms inside a group (capped at :data:`_TAG_MAX_TERMS_PER_GROUP`) 

1200 

1201 Examples: 

1202 - `"prod,staging"` => [["prod"], ["staging"]] 

1203 - `"mcp+critical"` => [["mcp", "critical"]] 

1204 - `"mcp+critical,ui"` => [["mcp", "critical"], ["ui"]] 

1205 

1206 Args: 

1207 tags (Optional[str]): Tag expression with comma-separated OR groups and 

1208 plus-separated AND terms. 

1209 

1210 Returns: 

1211 list[list[str]]: Parsed tag groups ready for SQL filter construction. 

1212 """ 

1213 if not tags: 

1214 return [] 

1215 

1216 groups: list[list[str]] = [] 

1217 for raw_group in tags.split(","): 

1218 if len(groups) >= _TAG_MAX_GROUPS: 

1219 break 

1220 candidate = [term.strip() for term in raw_group.split("+") if term.strip()][:_TAG_MAX_TERMS_PER_GROUP] 

1221 if candidate: 

1222 groups.append(candidate) 

1223 return groups 

1224 

1225 

1226def _apply_tag_filter_groups(query: Any, db: Session, column: Any, tag_groups: list[list[str]]) -> Any: 

1227 """Apply parsed tag filter groups to a SQLAlchemy query. 

1228 

1229 Args: 

1230 query (Any): SQLAlchemy ``select`` query to update. 

1231 db (Session): Database session. 

1232 column (Any): SQLAlchemy model column containing tags. 

1233 tag_groups (list[list[str]]): Parsed OR-of-AND tag groups. 

1234 

1235 Returns: 

1236 Any: Updated query with tag filters applied. 

1237 """ 

1238 if not tag_groups: 

1239 return query 

1240 

1241 group_exprs = [] 

1242 for group in tag_groups: 

1243 # Single term group => OR semantics (term exists) 

1244 # Multi-term group => AND semantics (all terms exist) 

1245 group_exprs.append(json_contains_tag_expr(db, column, group, match_any=len(group) == 1)) 

1246 

1247 if len(group_exprs) == 1: 

1248 return query.where(group_exprs[0]) 

1249 return query.where(or_(*group_exprs)) 

1250 

1251 

1252def _build_search_response( 

1253 *, 

1254 entity_key: str, 

1255 entity_type: str, 

1256 items: list[dict[str, Any]], 

1257 query: str, 

1258 tags: str, 

1259 tag_groups: list[list[str]], 

1260) -> dict[str, Any]: 

1261 """Build a consistent search response while preserving legacy keys. 

1262 

1263 Args: 

1264 entity_key (str): Legacy entity key (for example ``tools``). 

1265 entity_type (str): Canonical entity type label. 

1266 items (list[dict[str, Any]]): Serialized entity items. 

1267 query (str): Normalized free-text query. 

1268 tags (str): Normalized tag expression. 

1269 tag_groups (list[list[str]]): Parsed tag groups. 

1270 

1271 Returns: 

1272 dict[str, Any]: Unified search payload with legacy and standard keys. 

1273 """ 

1274 filters_applied = {"q": query, "tags": tags, "tag_groups": tag_groups} 

1275 return { 

1276 entity_key: items, # legacy key for backward compatibility 

1277 "items": items, 

1278 "count": len(items), 

1279 "entity_type": entity_type, 

1280 "query": query, 

1281 "filters_applied": filters_applied, 

1282 } 

1283 

1284 

1285@admin_router.get("/overview/partial") 

1286@require_permission("admin.overview", allow_admin_bypass=False) 

1287async def get_overview_partial( 

1288 request: Request, 

1289 db: Session = Depends(get_db), 

1290 user=Depends(get_current_user_with_permissions), 

1291) -> HTMLResponse: 

1292 """Render the overview dashboard partial HTML template. 

1293 

1294 This endpoint returns a rendered HTML partial containing an architecture 

1295 diagram showing ContextForge inputs (Virtual Servers), middleware (Plugins), 

1296 and outputs (A2A Agents, MCP Gateways, Tools, etc.) along with key metrics. 

1297 

1298 Args: 

1299 request: FastAPI request object 

1300 db: Database session 

1301 user: Authenticated user 

1302 

1303 Returns: 

1304 HTMLResponse with rendered overview partial template 

1305 """ 

1306 LOGGER.debug(f"User {get_user_email(user)} requested overview partial") 

1307 

1308 try: 

1309 # Gather counts for all entity types 

1310 # Note: SQLAlchemy func.count requires pylint disable=not-callable 

1311 # Virtual Servers (inputs) - uses 'enabled' field 

1312 servers_total = db.query(func.count(DbServer.id)).scalar() or 0 # pylint: disable=not-callable 

1313 servers_active = db.query(func.count(DbServer.id)).filter(DbServer.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable 

1314 

1315 # MCP Gateways - uses 'enabled' field 

1316 gateways_total = db.query(func.count(DbGateway.id)).scalar() or 0 # pylint: disable=not-callable 

1317 gateways_active = db.query(func.count(DbGateway.id)).filter(DbGateway.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable 

1318 

1319 # A2A Agents (if enabled) - uses 'enabled' field 

1320 a2a_total = 0 

1321 a2a_active = 0 

1322 if settings.mcpgateway_a2a_enabled: 

1323 a2a_total = db.query(func.count(DbA2AAgent.id)).scalar() or 0 # pylint: disable=not-callable 

1324 a2a_active = db.query(func.count(DbA2AAgent.id)).filter(DbA2AAgent.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable 

1325 

1326 # Tools - uses 'enabled' field 

1327 tools_total = db.query(func.count(DbTool.id)).scalar() or 0 # pylint: disable=not-callable 

1328 tools_active = db.query(func.count(DbTool.id)).filter(DbTool.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable 

1329 

1330 # Prompts - uses 'enabled' field 

1331 prompts_total = db.query(func.count(DbPrompt.id)).scalar() or 0 # pylint: disable=not-callable 

1332 prompts_active = db.query(func.count(DbPrompt.id)).filter(DbPrompt.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable 

1333 

1334 # Resources - uses 'enabled' field 

1335 resources_total = db.query(func.count(DbResource.id)).scalar() or 0 # pylint: disable=not-callable 

1336 resources_active = db.query(func.count(DbResource.id)).filter(DbResource.enabled.is_(True)).scalar() or 0 # pylint: disable=not-callable 

1337 

1338 # Plugin stats 

1339 overview_plugin_service = get_plugin_service() 

1340 plugin_manager = getattr(request.app.state, "plugin_manager", None) 

1341 if plugin_manager: 

1342 overview_plugin_service.set_plugin_manager(plugin_manager) 

1343 plugin_stats = await overview_plugin_service.get_plugin_statistics() 

1344 

1345 # Infrastructure status (database, cache, uptime) 

1346 _, db_reachable = version_module._database_version() # pylint: disable=protected-access 

1347 db_dialect = version_module.engine.dialect.name 

1348 cache_type = settings.cache_type 

1349 uptime_seconds = int(time.time() - version_module.START_TIME) 

1350 

1351 # Redis status (if applicable) 

1352 redis_available = version_module.REDIS_AVAILABLE 

1353 redis_reachable = False 

1354 if redis_available and cache_type.lower() == "redis" and settings.redis_url: 

1355 try: 

1356 # First-Party 

1357 from mcpgateway.utils.redis_client import is_redis_available # pylint: disable=import-outside-toplevel 

1358 

1359 redis_reachable = await is_redis_available() 

1360 except Exception: 

1361 redis_reachable = False 

1362 

1363 # Aggregate metrics from services 

1364 overview_tool_service = ToolService() 

1365 overview_server_service = ServerService() 

1366 overview_prompt_service = PromptService() 

1367 overview_resource_service = ResourceService() 

1368 

1369 tool_metrics = await overview_tool_service.aggregate_metrics(db) 

1370 server_metrics = await overview_server_service.aggregate_metrics(db) 

1371 prompt_metrics = await overview_prompt_service.aggregate_metrics(db) 

1372 resource_metrics = await overview_resource_service.aggregate_metrics(db) 

1373 

1374 # Calculate totals 

1375 total_executions = ( 

1376 (tool_metrics.get("total_executions", 0) if isinstance(tool_metrics, dict) else getattr(tool_metrics, "total_executions", 0)) 

1377 + (server_metrics.total_executions if hasattr(server_metrics, "total_executions") else server_metrics.get("total_executions", 0)) 

1378 + (prompt_metrics.get("total_executions", 0) if isinstance(prompt_metrics, dict) else getattr(prompt_metrics, "total_executions", 0)) 

1379 + (resource_metrics.total_executions if hasattr(resource_metrics, "total_executions") else resource_metrics.get("total_executions", 0)) 

1380 ) 

1381 

1382 successful_executions = ( 

1383 (tool_metrics.get("successful_executions", 0) if isinstance(tool_metrics, dict) else getattr(tool_metrics, "successful_executions", 0)) 

1384 + (server_metrics.successful_executions if hasattr(server_metrics, "successful_executions") else server_metrics.get("successful_executions", 0)) 

1385 + (prompt_metrics.get("successful_executions", 0) if isinstance(prompt_metrics, dict) else getattr(prompt_metrics, "successful_executions", 0)) 

1386 + (resource_metrics.successful_executions if hasattr(resource_metrics, "successful_executions") else resource_metrics.get("successful_executions", 0)) 

1387 ) 

1388 

1389 success_rate = (successful_executions / total_executions * 100) if total_executions > 0 else 100.0 

1390 

1391 # Calculate average latency across all services 

1392 latencies = [] 

1393 for m in [tool_metrics, server_metrics, prompt_metrics, resource_metrics]: 

1394 avg_time = m.get("avg_response_time") if isinstance(m, dict) else getattr(m, "avg_response_time", None) 

1395 if avg_time is not None: 

1396 latencies.append(avg_time) 

1397 avg_latency = sum(latencies) / len(latencies) if latencies else 0.0 

1398 

1399 # Prepare context 

1400 context = { 

1401 "request": request, 

1402 "root_path": request.scope.get("root_path", ""), 

1403 # Inputs 

1404 "servers_total": servers_total, 

1405 "servers_active": servers_active, 

1406 # Outputs 

1407 "gateways_total": gateways_total, 

1408 "gateways_active": gateways_active, 

1409 "a2a_total": a2a_total, 

1410 "a2a_active": a2a_active, 

1411 "a2a_enabled": settings.mcpgateway_a2a_enabled, 

1412 "tools_total": tools_total, 

1413 "tools_active": tools_active, 

1414 "prompts_total": prompts_total, 

1415 "prompts_active": prompts_active, 

1416 "resources_total": resources_total, 

1417 "resources_active": resources_active, 

1418 # Plugins (plugin_stats can be dict or PluginStatsResponse) 

1419 "plugins_total": plugin_stats.get("total_plugins", 0) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "total_plugins", 0), 

1420 "plugins_enabled": plugin_stats.get("enabled_plugins", 0) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "enabled_plugins", 0), 

1421 "plugins_by_hook": plugin_stats.get("plugins_by_hook", {}) if isinstance(plugin_stats, dict) else getattr(plugin_stats, "plugins_by_hook", {}), 

1422 # Metrics 

1423 "total_executions": total_executions, 

1424 "success_rate": success_rate, 

1425 "avg_latency_ms": avg_latency * 1000 if avg_latency else 0.0, 

1426 # Version 

1427 "version": __version__, 

1428 # Infrastructure 

1429 "db_dialect": db_dialect, 

1430 "db_reachable": db_reachable, 

1431 "cache_type": cache_type, 

1432 "redis_available": redis_available, 

1433 "redis_reachable": redis_reachable, 

1434 "uptime_seconds": uptime_seconds, 

1435 } 

1436 

1437 return request.app.state.templates.TemplateResponse(request, "overview_partial.html", context) 

1438 

1439 except Exception as e: 

1440 LOGGER.error(f"Error rendering overview partial: {e}") 

1441 error_html = f""" 

1442 <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded"> 

1443 <strong class="font-bold">Error loading overview:</strong> 

1444 <span class="block sm:inline">{html.escape(str(e))}</span> 

1445 </div> 

1446 """ 

1447 return HTMLResponse(content=error_html, status_code=500) 

1448 

1449 

1450@admin_router.get("/config/passthrough-headers", response_model=GlobalConfigRead) 

1451@require_permission("admin.system_config", allow_admin_bypass=False) 

1452@rate_limit(requests_per_minute=30) # Lower limit for config endpoints 

1453async def get_global_passthrough_headers( 

1454 db: Session = Depends(get_db), 

1455 _user=Depends(get_current_user_with_permissions), 

1456) -> GlobalConfigRead: 

1457 """Get the global passthrough headers configuration. 

1458 

1459 Args: 

1460 db: Database session 

1461 _user: Authenticated user 

1462 

1463 Returns: 

1464 GlobalConfigRead: The current global passthrough headers configuration 

1465 

1466 Examples: 

1467 >>> # Test function exists and has correct name 

1468 >>> from mcpgateway.admin import get_global_passthrough_headers 

1469 >>> get_global_passthrough_headers.__name__ 

1470 'get_global_passthrough_headers' 

1471 >>> # Test it's a coroutine function 

1472 >>> import inspect 

1473 >>> inspect.iscoroutinefunction(get_global_passthrough_headers) 

1474 True 

1475 """ 

1476 # Use cache for reads (Issue #1715) 

1477 # Pass env defaults so env/merge modes return correct headers 

1478 passthrough_headers = global_config_cache.get_passthrough_headers(db, settings.default_passthrough_headers) 

1479 return GlobalConfigRead(passthrough_headers=passthrough_headers) 

1480 

1481 

1482@admin_router.put("/config/passthrough-headers", response_model=GlobalConfigRead) 

1483@require_permission("admin.system_config", allow_admin_bypass=False) 

1484@rate_limit(requests_per_minute=20) # Stricter limit for config updates 

1485async def update_global_passthrough_headers( 

1486 request: Request, # pylint: disable=unused-argument 

1487 config_update: GlobalConfigUpdate, 

1488 db: Session = Depends(get_db), 

1489 _user=Depends(get_current_user_with_permissions), 

1490) -> GlobalConfigRead: 

1491 """Update the global passthrough headers configuration. 

1492 

1493 Args: 

1494 request: HTTP request object 

1495 config_update: The new configuration 

1496 db: Database session 

1497 _user: Authenticated user 

1498 

1499 Raises: 

1500 HTTPException: If there is a conflict or validation error 

1501 

1502 Returns: 

1503 GlobalConfigRead: The updated configuration 

1504 

1505 Examples: 

1506 >>> # Test function exists and has correct name 

1507 >>> from mcpgateway.admin import update_global_passthrough_headers 

1508 >>> update_global_passthrough_headers.__name__ 

1509 'update_global_passthrough_headers' 

1510 >>> # Test it's a coroutine function 

1511 >>> import inspect 

1512 >>> inspect.iscoroutinefunction(update_global_passthrough_headers) 

1513 True 

1514 """ 

1515 try: 

1516 config = db.query(GlobalConfig).first() 

1517 if not config: 

1518 config = GlobalConfig(passthrough_headers=config_update.passthrough_headers) 

1519 db.add(config) 

1520 else: 

1521 config.passthrough_headers = config_update.passthrough_headers 

1522 db.commit() 

1523 # Invalidate cache so changes propagate immediately (Issue #1715) 

1524 global_config_cache.invalidate() 

1525 return GlobalConfigRead(passthrough_headers=config.passthrough_headers) 

1526 except IntegrityError as e: 

1527 db.rollback() 

1528 raise HTTPException(status_code=409, detail="Passthrough headers conflict") from e 

1529 except ValidationError as e: 

1530 db.rollback() 

1531 raise HTTPException(status_code=422, detail="Invalid passthrough headers format") from e 

1532 except PassthroughHeadersError as e: 

1533 db.rollback() 

1534 raise HTTPException(status_code=500, detail=str(e)) from e 

1535 

1536 

1537@admin_router.post("/config/passthrough-headers/invalidate-cache") 

1538@require_permission("admin.system_config", allow_admin_bypass=False) 

1539@rate_limit(requests_per_minute=10) # Strict limit for cache operations 

1540async def invalidate_passthrough_headers_cache( 

1541 _user=Depends(get_current_user_with_permissions), 

1542 _db: Session = Depends(get_db), 

1543) -> Dict[str, Any]: 

1544 """Invalidate the GlobalConfig cache. 

1545 

1546 Forces an immediate cache refresh on the next access. Use this after 

1547 updating GlobalConfig outside the normal API flow, or when you need 

1548 changes to propagate immediately across all workers. 

1549 

1550 Args: 

1551 _user: Authenticated user 

1552 _db: Database session for permission checks. 

1553 

1554 Returns: 

1555 Dict with invalidation status and cache statistics 

1556 

1557 Examples: 

1558 >>> # Test function exists and has correct name 

1559 >>> from mcpgateway.admin import invalidate_passthrough_headers_cache 

1560 >>> invalidate_passthrough_headers_cache.__name__ 

1561 'invalidate_passthrough_headers_cache' 

1562 >>> # Test it's a coroutine function 

1563 >>> import inspect 

1564 >>> inspect.iscoroutinefunction(invalidate_passthrough_headers_cache) 

1565 True 

1566 """ 

1567 global_config_cache.invalidate() 

1568 stats = global_config_cache.stats() 

1569 return { 

1570 "status": "invalidated", 

1571 "message": "GlobalConfig cache invalidated successfully", 

1572 "cache_stats": stats, 

1573 } 

1574 

1575 

1576@admin_router.get("/config/passthrough-headers/cache-stats") 

1577@require_permission("admin.system_config", allow_admin_bypass=False) 

1578@rate_limit(requests_per_minute=30) 

1579async def get_passthrough_headers_cache_stats( 

1580 _user=Depends(get_current_user_with_permissions), 

1581 _db: Session = Depends(get_db), 

1582) -> Dict[str, Any]: 

1583 """Get GlobalConfig cache statistics. 

1584 

1585 Returns cache hit/miss counts, hit rate, TTL, and current cache status. 

1586 Useful for monitoring cache effectiveness and debugging. 

1587 

1588 Args: 

1589 _user: Authenticated user 

1590 _db: Database session for permission checks. 

1591 

1592 Returns: 

1593 Dict with cache statistics 

1594 

1595 Examples: 

1596 >>> # Test function exists and has correct name 

1597 >>> from mcpgateway.admin import get_passthrough_headers_cache_stats 

1598 >>> get_passthrough_headers_cache_stats.__name__ 

1599 'get_passthrough_headers_cache_stats' 

1600 >>> # Test it's a coroutine function 

1601 >>> import inspect 

1602 >>> inspect.iscoroutinefunction(get_passthrough_headers_cache_stats) 

1603 True 

1604 """ 

1605 return global_config_cache.stats() 

1606 

1607 

1608# =================================== 

1609# A2A Stats Cache Endpoints 

1610# =================================== 

1611 

1612 

1613@admin_router.post("/cache/a2a-stats/invalidate") 

1614@require_permission("admin.system_config", allow_admin_bypass=False) 

1615@rate_limit(requests_per_minute=10) 

1616async def invalidate_a2a_stats_cache( 

1617 _user=Depends(get_current_user_with_permissions), 

1618 _db: Session = Depends(get_db), 

1619) -> Dict[str, Any]: 

1620 """Invalidate the A2A stats cache. 

1621 

1622 Forces an immediate cache refresh on the next access. Use this after 

1623 modifying A2A agents outside the normal API flow, or when you need 

1624 changes to propagate immediately. 

1625 

1626 Args: 

1627 _user: Authenticated user 

1628 _db: Database session for permission checks. 

1629 

1630 Returns: 

1631 Dict with invalidation status and cache statistics 

1632 

1633 Examples: 

1634 >>> from mcpgateway.admin import invalidate_a2a_stats_cache 

1635 >>> invalidate_a2a_stats_cache.__name__ 

1636 'invalidate_a2a_stats_cache' 

1637 >>> import inspect 

1638 >>> inspect.iscoroutinefunction(invalidate_a2a_stats_cache) 

1639 True 

1640 """ 

1641 a2a_stats_cache.invalidate() 

1642 stats = a2a_stats_cache.stats() 

1643 return { 

1644 "status": "invalidated", 

1645 "message": "A2A stats cache invalidated successfully", 

1646 "cache_stats": stats, 

1647 } 

1648 

1649 

1650@admin_router.get("/cache/a2a-stats/stats") 

1651@require_permission("admin.system_config", allow_admin_bypass=False) 

1652@rate_limit(requests_per_minute=30) 

1653async def get_a2a_stats_cache_stats( 

1654 _user=Depends(get_current_user_with_permissions), 

1655 _db: Session = Depends(get_db), 

1656) -> Dict[str, Any]: 

1657 """Get A2A stats cache statistics. 

1658 

1659 Returns cache hit/miss counts, hit rate, TTL, and current cache status. 

1660 Useful for monitoring cache effectiveness and debugging. 

1661 

1662 Args: 

1663 _user: Authenticated user 

1664 _db: Database session for permission checks. 

1665 

1666 Returns: 

1667 Dict with cache statistics 

1668 

1669 Examples: 

1670 >>> from mcpgateway.admin import get_a2a_stats_cache_stats 

1671 >>> get_a2a_stats_cache_stats.__name__ 

1672 'get_a2a_stats_cache_stats' 

1673 >>> import inspect 

1674 >>> inspect.iscoroutinefunction(get_a2a_stats_cache_stats) 

1675 True 

1676 """ 

1677 return a2a_stats_cache.stats() 

1678 

1679 

1680@admin_router.get("/mcp-pool/metrics") 

1681@require_permission("admin.system_config", allow_admin_bypass=False) 

1682@rate_limit(requests_per_minute=60) 

1683async def get_mcp_session_pool_metrics( 

1684 request: Request, # pylint: disable=unused-argument 

1685 _user=Depends(get_current_user_with_permissions), 

1686 _db: Session = Depends(get_db), 

1687) -> Dict[str, Any]: 

1688 """Get MCP session pool metrics. 

1689 

1690 Returns pool statistics including hits, misses, evictions, hit rate, 

1691 circuit breaker status, and per-pool details. Useful for monitoring 

1692 pool effectiveness and diagnosing connection issues. 

1693 

1694 Args: 

1695 request: HTTP request object (required by rate_limit decorator) 

1696 _user: Authenticated user 

1697 _db: Database session for permission checks. 

1698 

1699 Returns: 

1700 Dict with pool metrics including: 

1701 - hits: Number of pool hits (session reuse) 

1702 - misses: Number of pool misses (new session created) 

1703 - evictions: Number of sessions evicted due to TTL 

1704 - health_check_failures: Number of failed health checks 

1705 - circuit_breaker_trips: Number of circuit breaker activations 

1706 - pool_keys_evicted: Number of idle pool keys cleaned up 

1707 - sessions_reaped: Number of stale sessions closed by background reaper 

1708 - hit_rate: Ratio of hits to total requests (0.0-1.0) 

1709 - pool_key_count: Number of active pool keys 

1710 - pools: Per-pool statistics (available, active, max) 

1711 - circuit_breakers: Circuit breaker status per URL 

1712 

1713 Raises: 

1714 HTTPException: If session pool is not initialized 

1715 

1716 Examples: 

1717 >>> from mcpgateway.admin import get_mcp_session_pool_metrics 

1718 >>> get_mcp_session_pool_metrics.__name__ 

1719 'get_mcp_session_pool_metrics' 

1720 >>> import inspect 

1721 >>> inspect.iscoroutinefunction(get_mcp_session_pool_metrics) 

1722 True 

1723 """ 

1724 if not settings.mcp_session_pool_enabled: 

1725 return {"enabled": False, "message": "MCP session pool is disabled"} 

1726 

1727 try: 

1728 pool = get_mcp_session_pool() 

1729 metrics = pool.get_metrics() 

1730 return {"enabled": True, **metrics} 

1731 except RuntimeError as e: 

1732 return {"enabled": True, "error": str(e), "message": "Pool not yet initialized"} 

1733 

1734 

1735@admin_router.get("/config/settings") 

1736@require_permission("admin.system_config", allow_admin_bypass=False) 

1737async def get_configuration_settings( 

1738 _db: Session = Depends(get_db), 

1739 _user=Depends(get_current_user_with_permissions), 

1740) -> Dict[str, Any]: 

1741 """Get application configuration settings grouped by category. 

1742 

1743 Returns configuration settings with sensitive values masked. 

1744 

1745 Args: 

1746 _db: Database session 

1747 _user: Authenticated user 

1748 

1749 Returns: 

1750 Dict with configuration groups and their settings 

1751 """ 

1752 

1753 def mask_sensitive(value: Any, key: str) -> Any: 

1754 """Mask sensitive configuration values. 

1755 

1756 Args: 

1757 value: Configuration value to potentially mask 

1758 key: Configuration key name to check for sensitive patterns 

1759 

1760 Returns: 

1761 Masked value if sensitive, original value otherwise 

1762 """ 

1763 sensitive_keys = {"password", "secret", "key", "token", "credentials", "client_secret", "private_key", "auth_encryption_secret"} 

1764 if any(s in key.lower() for s in sensitive_keys): 

1765 # Handle SecretStr objects 

1766 if isinstance(value, SecretStr): 

1767 return settings.masked_auth_value 

1768 if value and str(value) not in ["", "None", "null"]: 

1769 return settings.masked_auth_value 

1770 return value 

1771 

1772 # Group settings by category 

1773 config_groups = { 

1774 "Basic Settings": { 

1775 "app_name": settings.app_name, 

1776 "host": settings.host, 

1777 "port": settings.port, 

1778 "environment": settings.environment, 

1779 "app_domain": str(settings.app_domain), 

1780 "protocol_version": settings.protocol_version, 

1781 }, 

1782 "Authentication & Security": { 

1783 "auth_required": settings.auth_required, 

1784 "basic_auth_user": settings.basic_auth_user, 

1785 "basic_auth_password": mask_sensitive(settings.basic_auth_password, "password"), 

1786 "jwt_algorithm": settings.jwt_algorithm, 

1787 "jwt_secret_key": mask_sensitive(settings.jwt_secret_key, "secret_key"), 

1788 "jwt_audience": settings.jwt_audience, 

1789 "jwt_issuer": settings.jwt_issuer, 

1790 "token_expiry": settings.token_expiry, 

1791 "require_token_expiration": settings.require_token_expiration, 

1792 "mcp_client_auth_enabled": settings.mcp_client_auth_enabled, 

1793 "trust_proxy_auth": settings.trust_proxy_auth, 

1794 "skip_ssl_verify": settings.skip_ssl_verify, 

1795 }, 

1796 "SSO Configuration": { 

1797 "sso_enabled": settings.sso_enabled, 

1798 "sso_github_enabled": settings.sso_github_enabled, 

1799 "sso_google_enabled": settings.sso_google_enabled, 

1800 "sso_ibm_verify_enabled": settings.sso_ibm_verify_enabled, 

1801 "sso_okta_enabled": settings.sso_okta_enabled, 

1802 "sso_keycloak_enabled": settings.sso_keycloak_enabled, 

1803 "sso_entra_enabled": settings.sso_entra_enabled, 

1804 "sso_generic_enabled": settings.sso_generic_enabled, 

1805 "sso_auto_create_users": settings.sso_auto_create_users, 

1806 "sso_preserve_admin_auth": settings.sso_preserve_admin_auth, 

1807 "sso_require_admin_approval": settings.sso_require_admin_approval, 

1808 }, 

1809 "Email Authentication": { 

1810 "email_auth_enabled": settings.email_auth_enabled, 

1811 "platform_admin_email": settings.platform_admin_email, 

1812 "platform_admin_password": mask_sensitive(settings.platform_admin_password, "password"), 

1813 }, 

1814 "Database & Cache": { 

1815 "database_url": settings.database_url.replace("://", "://***@") if "@" in settings.database_url else settings.database_url, 

1816 "cache_type": settings.cache_type, 

1817 "redis_url": settings.redis_url.replace("://", "://***@") if settings.redis_url and "@" in settings.redis_url else settings.redis_url, 

1818 "db_pool_size": settings.db_pool_size, 

1819 "db_max_overflow": settings.db_max_overflow, 

1820 }, 

1821 "Feature Flags": { 

1822 "mcpgateway_ui_enabled": settings.mcpgateway_ui_enabled, 

1823 "mcpgateway_admin_api_enabled": settings.mcpgateway_admin_api_enabled, 

1824 "mcpgateway_bulk_import_enabled": settings.mcpgateway_bulk_import_enabled, 

1825 "mcpgateway_a2a_enabled": settings.mcpgateway_a2a_enabled, 

1826 "mcpgateway_catalog_enabled": settings.mcpgateway_catalog_enabled, 

1827 "plugins_enabled": settings.plugins_enabled, 

1828 "well_known_enabled": settings.well_known_enabled, 

1829 "mcpgateway_direct_proxy_enabled": settings.mcpgateway_direct_proxy_enabled, 

1830 }, 

1831 "Connection Timeouts": { 

1832 "federation_timeout": settings.federation_timeout, # Gateway/server HTTP request timeout 

1833 "mcpgateway_direct_proxy_timeout": settings.mcpgateway_direct_proxy_timeout, 

1834 }, 

1835 "Transport": { 

1836 "transport_type": settings.transport_type, 

1837 "websocket_ping_interval": settings.websocket_ping_interval, 

1838 "sse_retry_timeout": settings.sse_retry_timeout, 

1839 "sse_keepalive_enabled": settings.sse_keepalive_enabled, 

1840 }, 

1841 "Logging": { 

1842 "log_level": settings.log_level, 

1843 "log_format": settings.log_format, 

1844 "log_to_file": settings.log_to_file, 

1845 "log_file": settings.log_file, 

1846 "log_rotation_enabled": settings.log_rotation_enabled, 

1847 }, 

1848 "Resources & Tools": { 

1849 "tool_timeout": settings.tool_timeout, 

1850 "tool_rate_limit": settings.tool_rate_limit, 

1851 "tool_concurrent_limit": settings.tool_concurrent_limit, 

1852 "resource_cache_size": settings.resource_cache_size, 

1853 "resource_cache_ttl": settings.resource_cache_ttl, 

1854 "max_resource_size": settings.max_resource_size, 

1855 }, 

1856 "CORS Settings": { 

1857 "cors_enabled": settings.cors_enabled, 

1858 "allowed_origins": list(settings.allowed_origins), 

1859 "cors_allow_credentials": settings.cors_allow_credentials, 

1860 }, 

1861 "Security Headers": { 

1862 "security_headers_enabled": settings.security_headers_enabled, 

1863 "x_frame_options": settings.x_frame_options, 

1864 "hsts_enabled": settings.hsts_enabled, 

1865 "hsts_max_age": settings.hsts_max_age, 

1866 "remove_server_headers": settings.remove_server_headers, 

1867 }, 

1868 "Observability": { 

1869 "otel_enable_observability": settings.otel_enable_observability, 

1870 "otel_traces_exporter": settings.otel_traces_exporter, 

1871 "otel_service_name": settings.otel_service_name, 

1872 }, 

1873 "Development": { 

1874 "dev_mode": settings.dev_mode, 

1875 "reload": settings.reload, 

1876 "debug": settings.debug, 

1877 }, 

1878 } 

1879 

1880 return { 

1881 "groups": config_groups, 

1882 "security_status": settings.get_security_status(), 

1883 } 

1884 

1885 

1886@admin_router.get("/servers", response_model=PaginatedResponse) 

1887@require_permission("servers.read", allow_admin_bypass=False) 

1888async def admin_list_servers( 

1889 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

1890 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

1891 include_inactive: bool = False, 

1892 db: Session = Depends(get_db), 

1893 user=Depends(get_current_user_with_permissions), 

1894) -> Dict[str, Any]: 

1895 """ 

1896 List servers for the admin UI with pagination support. 

1897 

1898 This endpoint retrieves a paginated list of servers from the database, optionally 

1899 including those that are inactive. Uses offset-based (page/per_page) pagination. 

1900 

1901 Args: 

1902 page (int): Page number (1-indexed) for offset pagination. 

1903 per_page (int): Number of items per page. 

1904 include_inactive (bool): Whether to include inactive servers. 

1905 db (Session): The database session dependency. 

1906 user (str): The authenticated user dependency. 

1907 

1908 Returns: 

1909 Dict[str, Any]: A dictionary containing: 

1910 - data: List of server records formatted with by_alias=True 

1911 - pagination: Pagination metadata 

1912 - links: Pagination links (optional) 

1913 

1914 Examples: 

1915 >>> callable(admin_list_servers) 

1916 True 

1917 >>> admin_list_servers.__name__ 

1918 'admin_list_servers' 

1919 """ 

1920 LOGGER.debug(f"User {get_user_email(user)} requested server list (page={page}, per_page={per_page})") 

1921 user_email = get_user_email(user) 

1922 

1923 # Call server_service.list_servers with page-based pagination 

1924 paginated_result = await server_service.list_servers( 

1925 db=db, 

1926 include_inactive=include_inactive, 

1927 page=page, 

1928 per_page=per_page, 

1929 user_email=user_email, 

1930 ) 

1931 

1932 # End the read-only transaction early to avoid idle-in-transaction under load. 

1933 db.commit() 

1934 

1935 # Return standardized paginated response 

1936 return { 

1937 "data": [server.model_dump(by_alias=True) for server in paginated_result["data"]], 

1938 "pagination": paginated_result["pagination"].model_dump(), 

1939 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

1940 } 

1941 

1942 

1943@admin_router.get("/servers/partial", response_class=HTMLResponse) 

1944@require_permission("servers.read", allow_admin_bypass=False) 

1945async def admin_servers_partial_html( 

1946 request: Request, 

1947 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

1948 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

1949 q: str = Query("", description="Search query"), 

1950 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

1951 include_inactive: bool = False, 

1952 render: Optional[str] = Query(None), 

1953 team_id: Optional[str] = Depends(_validated_team_id_param), 

1954 db: Session = Depends(get_db), 

1955 user=Depends(get_current_user_with_permissions), 

1956): 

1957 """Return paginated servers HTML partials for the admin UI. 

1958 

1959 This HTMX endpoint returns only the partial HTML used by the admin UI for 

1960 servers. It supports three render modes: 

1961 

1962 - default: full table partial (rows + controls) 

1963 - ``render="controls"``: return only pagination controls 

1964 - ``render="selector"``: return selector items for infinite scroll 

1965 

1966 Args: 

1967 request (Request): FastAPI request object used by the template engine. 

1968 page (int): Page number (1-indexed). 

1969 per_page (int): Number of items per page (bounded by settings). 

1970 q (str): Free-text query string. 

1971 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

1972 include_inactive (bool): If True, include inactive servers in results. 

1973 render (Optional[str]): Render mode; one of None, "controls", "selector". 

1974 team_id (Optional[str]): Filter by team ID. 

1975 db (Session): Database session (dependency-injected). 

1976 user: Authenticated user object from dependency injection. 

1977 

1978 Returns: 

1979 Union[HTMLResponse, TemplateResponse]: A rendered template response 

1980 containing either the table partial, pagination controls, or selector 

1981 items depending on ``render``. The response contains JSON-serializable 

1982 encoded server data when templates expect it. 

1983 """ 

1984 LOGGER.debug(f"User {get_user_email(user)} requested servers HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, team_id={team_id})") 

1985 search_query = _normalize_search_query(q) 

1986 normalized_tags = _normalize_tags_query(tags) 

1987 tag_groups = _parse_tag_filter_groups(normalized_tags) 

1988 

1989 # Normalize per_page within configured bounds 

1990 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) 

1991 

1992 user_email = get_user_email(user) 

1993 

1994 # Team scoping 

1995 team_ids = await _get_user_team_ids(user, db) 

1996 

1997 # Build base query with eager loading to avoid N+1 queries 

1998 query = select(DbServer).options( 

1999 selectinload(DbServer.tools), 

2000 selectinload(DbServer.resources), 

2001 selectinload(DbServer.prompts), 

2002 selectinload(DbServer.a2a_agents), 

2003 joinedload(DbServer.email_team), 

2004 ) 

2005 

2006 if not include_inactive: 

2007 query = query.where(DbServer.enabled.is_(True)) 

2008 

2009 # Build access conditions 

2010 # When team_id is specified, show ONLY items from that team (team-scoped view) 

2011 # Otherwise, show all accessible items (All Teams view) 

2012 if team_id: 

2013 # Team-specific view: only show servers from the specified team 

2014 if team_id in team_ids: 

2015 # Apply visibility check: team/public resources + user's own resources (including private) 

2016 team_access = [ 

2017 and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])), 

2018 and_(DbServer.team_id == team_id, DbServer.owner_email == user_email), 

2019 ] 

2020 query = query.where(or_(*team_access)) 

2021 LOGGER.debug(f"Filtering servers by team_id: {team_id}") 

2022 else: 

2023 # User is not a member of this team, return no results using SQLAlchemy's false() 

2024 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

2025 query = query.where(false()) 

2026 else: 

2027 # All Teams view: apply standard access conditions (owner, team, public) 

2028 access_conditions = [] 

2029 access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

2030 if team_ids: 

2031 access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"]))) 

2032 access_conditions.append(DbServer.visibility == "public") 

2033 query = query.where(or_(*access_conditions)) 

2034 

2035 if search_query: 

2036 query = query.where( 

2037 or_( 

2038 _like_contains(func.lower(DbServer.id), search_query), 

2039 _like_contains(func.lower(DbServer.name), search_query), 

2040 _like_contains(func.lower(coalesce(DbServer.description, "")), search_query), 

2041 ) 

2042 ) 

2043 

2044 query = _apply_tag_filter_groups(query, db, DbServer.tags, tag_groups) 

2045 

2046 # Apply pagination ordering for cursor support 

2047 query = query.order_by(desc(DbServer.created_at), desc(DbServer.id)) 

2048 

2049 # Build query params for pagination links 

2050 query_params = {} 

2051 if include_inactive: 

2052 query_params["include_inactive"] = "true" 

2053 if team_id: 

2054 query_params["team_id"] = team_id 

2055 if search_query: 

2056 query_params["q"] = search_query 

2057 if normalized_tags: 

2058 query_params["tags"] = normalized_tags 

2059 

2060 # Use unified pagination function 

2061 root_path = request.scope.get("root_path", "") 

2062 base_url = f"{root_path}/admin/servers/partial" 

2063 paginated_result = await paginate_query( 

2064 db=db, 

2065 query=query, 

2066 page=page, 

2067 per_page=per_page, 

2068 cursor=None, # HTMX partials use page-based navigation 

2069 base_url=base_url, 

2070 query_params=query_params, 

2071 use_cursor_threshold=False, # Disable auto-cursor switching for UI 

2072 ) 

2073 

2074 # Extract paginated servers (DbServer objects) 

2075 servers_db = paginated_result["data"] 

2076 pagination = paginated_result["pagination"] 

2077 links = paginated_result["links"] 

2078 

2079 # Team names are loaded via joinedload(DbServer.email_team) and accessed via server.team property 

2080 

2081 # Batch convert to Pydantic models using server service 

2082 # This eliminates the N+1 query problem from calling get_server_details() in a loop 

2083 servers_pydantic = [] 

2084 failed_count = 0 

2085 for s in servers_db: 

2086 try: 

2087 servers_pydantic.append(server_service.convert_server_to_read(s, include_metrics=False)) 

2088 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e: 

2089 failed_count += 1 

2090 LOGGER.exception(f"Failed to convert server {getattr(s, 'id', 'unknown')} ({getattr(s, 'name', 'unknown')}): {e}") 

2091 _adjust_pagination_for_conversion_failures(pagination, failed_count) 

2092 data = jsonable_encoder(servers_pydantic) 

2093 

2094 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

2095 db.commit() 

2096 

2097 if render == "controls": 

2098 return request.app.state.templates.TemplateResponse( 

2099 request, 

2100 "pagination_controls.html", 

2101 { 

2102 "request": request, 

2103 "pagination": pagination.model_dump(), 

2104 "base_url": base_url, 

2105 "hx_target": "#servers-table-body", 

2106 "hx_indicator": "#servers-loading", 

2107 "query_params": query_params, 

2108 "root_path": request.scope.get("root_path", ""), 

2109 }, 

2110 ) 

2111 

2112 if render == "selector": 

2113 return request.app.state.templates.TemplateResponse( 

2114 request, 

2115 "servers_selector_items.html", 

2116 { 

2117 "request": request, 

2118 "data": data, 

2119 "pagination": pagination.model_dump(), 

2120 "root_path": request.scope.get("root_path", ""), 

2121 }, 

2122 ) 

2123 

2124 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

2125 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

2126 return request.app.state.templates.TemplateResponse( 

2127 request, 

2128 "servers_partial.html", 

2129 { 

2130 "request": request, 

2131 "data": data, 

2132 "pagination": pagination.model_dump(), 

2133 "links": links.model_dump() if links else None, 

2134 "root_path": request.scope.get("root_path", ""), 

2135 "include_inactive": include_inactive, 

2136 "query_params": query_params, 

2137 "current_user_email": user_email, 

2138 "is_admin": _is_admin, 

2139 "user_team_roles": _team_roles, 

2140 }, 

2141 ) 

2142 

2143 

2144@admin_router.get("/servers/{server_id}", response_model=ServerRead) 

2145@require_permission("servers.read", allow_admin_bypass=False) 

2146async def admin_get_server(server_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

2147 """ 

2148 Retrieve server details for the admin UI. 

2149 

2150 Args: 

2151 server_id (str): The ID of the server to retrieve. 

2152 db (Session): The database session dependency. 

2153 user (str): The authenticated user dependency. 

2154 

2155 Returns: 

2156 Dict[str, Any]: The server details. 

2157 

2158 Raises: 

2159 HTTPException: If the server is not found. 

2160 Exception: For any other unexpected errors. 

2161 

2162 Examples: 

2163 >>> callable(admin_get_server) 

2164 True 

2165 >>> admin_get_server.__name__ 

2166 'admin_get_server' 

2167 """ 

2168 try: 

2169 LOGGER.debug(f"User {get_user_email(user)} requested details for server ID {server_id}") 

2170 server = await server_service.get_server(db, server_id) 

2171 return server.model_dump(by_alias=True) 

2172 except ServerNotFoundError as e: 

2173 raise HTTPException(status_code=404, detail=str(e)) 

2174 except Exception as e: 

2175 LOGGER.error(f"Error getting server {server_id}: {e}") 

2176 raise e 

2177 

2178 

2179@admin_router.post("/servers", response_model=ServerRead) 

2180@require_permission("servers.create", allow_admin_bypass=False) 

2181async def admin_add_server(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> JSONResponse: 

2182 """ 

2183 Add a new server via the admin UI. 

2184 

2185 This endpoint processes form data to create a new server entry in the database. 

2186 It handles exceptions gracefully and logs any errors that occur during server 

2187 registration. 

2188 

2189 Expects form fields: 

2190 - name (required): The name of the server 

2191 - description (optional): A description of the server's purpose 

2192 - icon (optional): URL or path to the server's icon 

2193 - associatedTools (optional, multiple values): Tools associated with this server 

2194 - associatedResources (optional, multiple values): Resources associated with this server 

2195 - associatedPrompts (optional, multiple values): Prompts associated with this server 

2196 

2197 Args: 

2198 request (Request): FastAPI request containing form data. 

2199 db (Session): Database session dependency 

2200 user (str): Authenticated user dependency 

2201 

2202 Returns: 

2203 JSONResponse: A JSON response indicating success or failure of the server creation operation. 

2204 

2205 Examples: 

2206 >>> # Test function exists and has correct name 

2207 >>> from mcpgateway.admin import admin_add_server 

2208 >>> admin_add_server.__name__ 

2209 'admin_add_server' 

2210 >>> # Test it's a coroutine function 

2211 >>> import inspect 

2212 >>> inspect.iscoroutinefunction(admin_add_server) 

2213 True 

2214 """ 

2215 form = await request.form() 

2216 # root_path = request.scope.get("root_path", "") 

2217 # is_inactive_checked = form.get("is_inactive_checked", "false") 

2218 

2219 # Parse tags from comma-separated string 

2220 tags_str = str(form.get("tags", "")) 

2221 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

2222 

2223 try: 

2224 LOGGER.debug(f"User {get_user_email(user)} is adding a new server with name: {form['name']}") 

2225 visibility = str(form.get("visibility", "private")) 

2226 

2227 # Handle "Select All" for tools 

2228 associated_tools_list = form.getlist("associatedTools") 

2229 if form.get("selectAllTools") == "true": 

2230 # User clicked "Select All" - get all tool IDs from hidden field 

2231 all_tool_ids_json = str(form.get("allToolIds", "[]")) 

2232 try: 

2233 all_tool_ids = orjson.loads(all_tool_ids_json) 

2234 associated_tools_list = all_tool_ids 

2235 LOGGER.info(f"Select All tools enabled: {len(all_tool_ids)} tools selected") 

2236 except orjson.JSONDecodeError: 

2237 LOGGER.warning("Failed to parse allToolIds JSON, falling back to checked tools") 

2238 

2239 # Handle "Select All" for resources 

2240 associated_resources_list = form.getlist("associatedResources") 

2241 if form.get("selectAllResources") == "true": 

2242 all_resource_ids_json = str(form.get("allResourceIds", "[]")) 

2243 try: 

2244 all_resource_ids = orjson.loads(all_resource_ids_json) 

2245 associated_resources_list = all_resource_ids 

2246 LOGGER.info(f"Select All resources enabled: {len(all_resource_ids)} resources selected") 

2247 except orjson.JSONDecodeError: 

2248 LOGGER.warning("Failed to parse allResourceIds JSON, falling back to checked resources") 

2249 

2250 # Handle "Select All" for prompts 

2251 associated_prompts_list = form.getlist("associatedPrompts") 

2252 if form.get("selectAllPrompts") == "true": 

2253 all_prompt_ids_json = str(form.get("allPromptIds", "[]")) 

2254 try: 

2255 all_prompt_ids = orjson.loads(all_prompt_ids_json) 

2256 associated_prompts_list = all_prompt_ids 

2257 LOGGER.info(f"Select All prompts enabled: {len(all_prompt_ids)} prompts selected") 

2258 except orjson.JSONDecodeError: 

2259 LOGGER.warning("Failed to parse allPromptIds JSON, falling back to checked prompts") 

2260 

2261 # Handle OAuth 2.0 configuration (RFC 9728) 

2262 oauth_enabled = form.get("oauth_enabled") == "on" 

2263 oauth_config = None 

2264 if oauth_enabled: 

2265 authorization_server = str(form.get("oauth_authorization_server", "")).strip() 

2266 scopes_str = str(form.get("oauth_scopes", "")).strip() 

2267 token_endpoint = str(form.get("oauth_token_endpoint", "")).strip() 

2268 

2269 if authorization_server: 

2270 oauth_config = {"authorization_servers": [authorization_server]} 

2271 if scopes_str: 

2272 # Convert space-separated scopes to list 

2273 oauth_config["scopes_supported"] = scopes_str.split() 

2274 if token_endpoint: 

2275 oauth_config["token_endpoint"] = token_endpoint 

2276 else: 

2277 # Invalid or incomplete OAuth configuration; disable OAuth to avoid inconsistent state 

2278 LOGGER.warning( 

2279 "OAuth was enabled for server '%s' but no authorization server was provided; disabling OAuth for this server.", 

2280 form.get("name"), 

2281 ) 

2282 oauth_enabled = False 

2283 oauth_config = None 

2284 

2285 server = ServerCreate( 

2286 id=form.get("id") or None, 

2287 name=form.get("name"), 

2288 description=form.get("description"), 

2289 icon=form.get("icon"), 

2290 associated_tools=",".join(str(x) for x in associated_tools_list), 

2291 associated_resources=",".join(str(x) for x in associated_resources_list), 

2292 associated_prompts=",".join(str(x) for x in associated_prompts_list), 

2293 tags=tags, 

2294 visibility=visibility, 

2295 oauth_enabled=oauth_enabled, 

2296 oauth_config=oauth_config, 

2297 ) 

2298 except KeyError as e: 

2299 # Convert KeyError to ValidationError-like response 

2300 return ORJSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422) 

2301 try: 

2302 user_email = get_user_email(user) 

2303 # Determine personal team for default assignment 

2304 team_id_raw = form.get("team_id", None) 

2305 team_id = str(team_id_raw) if team_id_raw is not None else None 

2306 

2307 team_service = TeamManagementService(db) 

2308 team_id = await team_service.verify_team_for_user(user_email, team_id) 

2309 

2310 # Extract metadata for server creation 

2311 creation_metadata = MetadataCapture.extract_creation_metadata(request, user) 

2312 

2313 # Ensure default visibility is private and assign to personal team when available 

2314 team_id_cast = typing_cast(Optional[str], team_id) 

2315 await server_service.register_server( 

2316 db, 

2317 server, 

2318 created_by=user_email, # Use the consistent user_email 

2319 created_from_ip=creation_metadata["created_from_ip"], 

2320 created_via=creation_metadata["created_via"], 

2321 created_user_agent=creation_metadata["created_user_agent"], 

2322 team_id=team_id_cast, 

2323 visibility=visibility, 

2324 ) 

2325 return ORJSONResponse( 

2326 content={"message": "Server created successfully!", "success": True}, 

2327 status_code=200, 

2328 ) 

2329 

2330 except CoreValidationError as ex: 

2331 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=422) 

2332 except ServerNameConflictError as ex: 

2333 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

2334 except ServerError as ex: 

2335 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

2336 # NOTE: Pydantic validation errors subclass ValueError; CoreValidationError must be handled first. 

2337 except ValueError as ex: 

2338 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400) 

2339 except IntegrityError as ex: 

2340 return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409) 

2341 except Exception as ex: 

2342 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

2343 

2344 

2345@admin_router.post("/servers/{server_id}/edit") 

2346@require_permission("servers.update", allow_admin_bypass=False) 

2347async def admin_edit_server( 

2348 server_id: str, 

2349 request: Request, 

2350 db: Session = Depends(get_db), 

2351 user=Depends(get_current_user_with_permissions), 

2352) -> JSONResponse: 

2353 """ 

2354 Edit an existing server via the admin UI. 

2355 

2356 This endpoint processes form data to update an existing server's properties. 

2357 It handles exceptions gracefully and logs any errors that occur during the 

2358 update operation. 

2359 

2360 Expects form fields: 

2361 - id (optional): Updated UUID for the server 

2362 - name (optional): The updated name of the server 

2363 - description (optional): An updated description of the server's purpose 

2364 - icon (optional): Updated URL or path to the server's icon 

2365 - associatedTools (optional, multiple values): Updated list of tools associated with this server 

2366 - associatedResources (optional, multiple values): Updated list of resources associated with this server 

2367 - associatedPrompts (optional, multiple values): Updated list of prompts associated with this server 

2368 

2369 Args: 

2370 server_id (str): The ID of the server to edit 

2371 request (Request): FastAPI request containing form data 

2372 db (Session): Database session dependency 

2373 user (str): Authenticated user dependency 

2374 

2375 Returns: 

2376 JSONResponse: A JSON response indicating success or failure of the server update operation. 

2377 

2378 Examples: 

2379 >>> callable(admin_edit_server) 

2380 True 

2381 >>> admin_edit_server.__name__ 

2382 'admin_edit_server' 

2383 """ 

2384 form = await request.form() 

2385 

2386 # Parse tags from comma-separated string 

2387 tags_str = str(form.get("tags", "")) 

2388 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

2389 try: 

2390 LOGGER.debug(f"User {get_user_email(user)} is editing server ID {server_id} with name: {form.get('name')}") 

2391 visibility = str(form.get("visibility", "private")) 

2392 user_email = get_user_email(user) 

2393 team_id_raw = form.get("team_id", None) 

2394 team_id = str(team_id_raw) if team_id_raw is not None else None 

2395 

2396 team_service = TeamManagementService(db) 

2397 team_id = await team_service.verify_team_for_user(user_email, team_id) 

2398 

2399 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) 

2400 

2401 # Handle "Select All" for tools 

2402 associated_tools_list = form.getlist("associatedTools") 

2403 if form.get("selectAllTools") == "true": 

2404 # User clicked "Select All" - get all tool IDs from hidden field 

2405 all_tool_ids_json = str(form.get("allToolIds", "[]")) 

2406 try: 

2407 all_tool_ids = orjson.loads(all_tool_ids_json) 

2408 associated_tools_list = all_tool_ids 

2409 LOGGER.info(f"Select All tools enabled for edit: {len(all_tool_ids)} tools selected") 

2410 except orjson.JSONDecodeError: 

2411 LOGGER.warning("Failed to parse allToolIds JSON, falling back to checked tools") 

2412 

2413 # Handle "Select All" for resources 

2414 associated_resources_list = form.getlist("associatedResources") 

2415 if form.get("selectAllResources") == "true": 

2416 all_resource_ids_json = str(form.get("allResourceIds", "[]")) 

2417 try: 

2418 all_resource_ids = orjson.loads(all_resource_ids_json) 

2419 associated_resources_list = all_resource_ids 

2420 LOGGER.info(f"Select All resources enabled for edit: {len(all_resource_ids)} resources selected") 

2421 except orjson.JSONDecodeError: 

2422 LOGGER.warning("Failed to parse allResourceIds JSON, falling back to checked resources") 

2423 

2424 # Handle "Select All" for prompts 

2425 associated_prompts_list = form.getlist("associatedPrompts") 

2426 if form.get("selectAllPrompts") == "true": 

2427 all_prompt_ids_json = str(form.get("allPromptIds", "[]")) 

2428 try: 

2429 all_prompt_ids = orjson.loads(all_prompt_ids_json) 

2430 associated_prompts_list = all_prompt_ids 

2431 LOGGER.info(f"Select All prompts enabled for edit: {len(all_prompt_ids)} prompts selected") 

2432 except orjson.JSONDecodeError: 

2433 LOGGER.warning("Failed to parse allPromptIds JSON, falling back to checked prompts") 

2434 

2435 # Handle OAuth 2.0 configuration (RFC 9728) 

2436 oauth_enabled = form.get("oauth_enabled") == "on" 

2437 oauth_config = None 

2438 if oauth_enabled: 

2439 authorization_server = str(form.get("oauth_authorization_server", "")).strip() 

2440 scopes_str = str(form.get("oauth_scopes", "")).strip() 

2441 token_endpoint = str(form.get("oauth_token_endpoint", "")).strip() 

2442 

2443 if authorization_server: 

2444 oauth_config = {"authorization_servers": [authorization_server]} 

2445 if scopes_str: 

2446 # Convert space-separated scopes to list 

2447 oauth_config["scopes_supported"] = scopes_str.split() 

2448 if token_endpoint: 

2449 oauth_config["token_endpoint"] = token_endpoint 

2450 else: 

2451 # Invalid or incomplete OAuth configuration; disable OAuth to avoid inconsistent state 

2452 LOGGER.warning( 

2453 "OAuth was enabled for server '%s' but no authorization server was provided; disabling OAuth for this server.", 

2454 form.get("name"), 

2455 ) 

2456 oauth_enabled = False 

2457 oauth_config = None 

2458 

2459 server = ServerUpdate( 

2460 id=form.get("id"), 

2461 name=form.get("name"), 

2462 description=form.get("description"), 

2463 icon=form.get("icon"), 

2464 associated_tools=",".join(str(x) for x in associated_tools_list), 

2465 associated_resources=",".join(str(x) for x in associated_resources_list), 

2466 associated_prompts=",".join(str(x) for x in associated_prompts_list), 

2467 tags=tags, 

2468 visibility=visibility, 

2469 team_id=team_id, 

2470 owner_email=user_email, 

2471 oauth_enabled=oauth_enabled, 

2472 oauth_config=oauth_config, 

2473 ) 

2474 

2475 await server_service.update_server( 

2476 db, 

2477 server_id, 

2478 server, 

2479 user_email, 

2480 modified_by=mod_metadata["modified_by"], 

2481 modified_from_ip=mod_metadata["modified_from_ip"], 

2482 modified_via=mod_metadata["modified_via"], 

2483 modified_user_agent=mod_metadata["modified_user_agent"], 

2484 ) 

2485 

2486 return ORJSONResponse( 

2487 content={"message": "Server updated successfully!", "success": True}, 

2488 status_code=200, 

2489 ) 

2490 except (ValidationError, CoreValidationError) as ex: 

2491 # Catch both Pydantic and pydantic_core validation errors 

2492 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

2493 except ServerNameConflictError as ex: 

2494 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

2495 except ServerError as ex: 

2496 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

2497 except ValueError as ex: 

2498 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400) 

2499 except RuntimeError as ex: 

2500 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

2501 except IntegrityError as ex: 

2502 return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409) 

2503 except PermissionError as e: 

2504 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}") 

2505 return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403) 

2506 except Exception as ex: 

2507 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

2508 

2509 

2510@admin_router.post("/servers/{server_id}/state") 

2511@require_permission("servers.update", allow_admin_bypass=False) 

2512async def admin_set_server_state( 

2513 server_id: str, 

2514 request: Request, 

2515 db: Session = Depends(get_db), 

2516 user=Depends(get_current_user_with_permissions), 

2517) -> Response: 

2518 """ 

2519 Set a server's active status via the admin UI. 

2520 

2521 This endpoint processes a form request to activate or deactivate a server. 

2522 It expects a form field 'activate' with value "true" to activate the server 

2523 or "false" to deactivate it. The endpoint handles exceptions gracefully and 

2524 logs any errors that might occur during the status change operation. 

2525 

2526 Args: 

2527 server_id (str): The ID of the server whose status to set. 

2528 request (Request): FastAPI request containing form data with the 'activate' field. 

2529 db (Session): Database session dependency. 

2530 user (str): Authenticated user dependency. 

2531 

2532 Returns: 

2533 Response: A redirect to the admin dashboard catalog section with a 

2534 status code of 303 (See Other). 

2535 

2536 Examples: 

2537 >>> callable(admin_set_server_state) 

2538 True 

2539 >>> admin_set_server_state.__name__ 

2540 'admin_set_server_state' 

2541 """ 

2542 form = await request.form() 

2543 error_message = None 

2544 user_email = get_user_email(user) 

2545 LOGGER.debug(f"User {user_email} is setting server ID {server_id} state with activate: {form.get('activate')}") 

2546 activate = str(form.get("activate", "true")).lower() == "true" 

2547 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

2548 try: 

2549 await server_service.set_server_state(db, server_id, activate, user_email=user_email) 

2550 except PermissionError as e: 

2551 LOGGER.warning(f"Permission denied for user {user_email} setting server {server_id} state: {e}") 

2552 error_message = str(e) 

2553 except ServerLockConflictError as e: 

2554 LOGGER.warning(f"Lock conflict for user {user_email} setting server {server_id} state: {e}") 

2555 error_message = "Server is being modified by another request. Please try again." 

2556 except Exception as e: 

2557 LOGGER.error(f"Error setting server status: {e}") 

2558 error_message = "Error setting server status. Please try again." 

2559 

2560 root_path = request.scope.get("root_path", "") 

2561 

2562 # Build redirect URL with error message if present 

2563 if error_message: 

2564 error_param = f"?error={urllib.parse.quote(error_message)}" 

2565 if is_inactive_checked.lower() == "true": 

2566 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#catalog", status_code=303) 

2567 return RedirectResponse(f"{root_path}/admin/{error_param}#catalog", status_code=303) 

2568 

2569 if is_inactive_checked.lower() == "true": 

2570 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303) 

2571 return RedirectResponse(f"{root_path}/admin#catalog", status_code=303) 

2572 

2573 

2574@admin_router.post("/servers/{server_id}/delete") 

2575@require_permission("servers.delete", allow_admin_bypass=False) 

2576async def admin_delete_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse: 

2577 """ 

2578 Delete a server via the admin UI. 

2579 

2580 This endpoint removes a server from the database by its ID. It handles exceptions 

2581 gracefully and logs any errors that occur during the deletion process. 

2582 

2583 Args: 

2584 server_id (str): The ID of the server to delete 

2585 request (Request): FastAPI request object (not used but required by route signature). 

2586 db (Session): Database session dependency 

2587 user (str): Authenticated user dependency 

2588 

2589 Returns: 

2590 RedirectResponse: A redirect to the admin dashboard catalog section with a 

2591 status code of 303 (See Other) 

2592 

2593 Examples: 

2594 >>> callable(admin_delete_server) 

2595 True 

2596 >>> admin_delete_server.__name__ 

2597 'admin_delete_server' 

2598 """ 

2599 form = await request.form() 

2600 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

2601 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true" 

2602 error_message = None 

2603 try: 

2604 user_email = get_user_email(user) 

2605 LOGGER.debug(f"User {user_email} is deleting server ID {server_id}") 

2606 await server_service.delete_server(db, server_id, user_email=user_email, purge_metrics=purge_metrics) 

2607 except PermissionError as e: 

2608 LOGGER.warning(f"Permission denied for user {get_user_email(user)} deleting server {server_id}: {e}") 

2609 error_message = str(e) 

2610 except Exception as e: 

2611 LOGGER.error(f"Error deleting server: {e}") 

2612 error_message = "Failed to delete server. Please try again." 

2613 

2614 root_path = request.scope.get("root_path", "") 

2615 

2616 # Build redirect URL with error message if present 

2617 if error_message: 

2618 error_param = f"?error={urllib.parse.quote(error_message)}" 

2619 if is_inactive_checked.lower() == "true": 

2620 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#catalog", status_code=303) 

2621 return RedirectResponse(f"{root_path}/admin/{error_param}#catalog", status_code=303) 

2622 

2623 if is_inactive_checked.lower() == "true": 

2624 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#catalog", status_code=303) 

2625 return RedirectResponse(f"{root_path}/admin#catalog", status_code=303) 

2626 

2627 

2628@admin_router.get("/resources", response_model=PaginatedResponse) 

2629@require_permission("resources.read", allow_admin_bypass=False) 

2630async def admin_list_resources( 

2631 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

2632 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

2633 include_inactive: bool = False, 

2634 db: Session = Depends(get_db), 

2635 user=Depends(get_current_user_with_permissions), 

2636) -> Dict[str, Any]: 

2637 """ 

2638 List resources for the admin UI with pagination support. 

2639 

2640 This endpoint retrieves a paginated list of resources from the database, optionally 

2641 including those that are inactive. Uses offset-based (page/per_page) pagination. 

2642 

2643 Args: 

2644 page (int): Page number (1-indexed). Default: 1. 

2645 per_page (int): Items per page. Default: 50. 

2646 include_inactive (bool): Whether to include inactive resources in the results. 

2647 db (Session): Database session dependency. 

2648 user (str): Authenticated user dependency. 

2649 

2650 Returns: 

2651 Dict with 'data', 'pagination', and 'links' keys containing paginated resources. 

2652 

2653 Examples: 

2654 >>> callable(admin_list_resources) 

2655 True 

2656 >>> admin_list_resources.__name__ 

2657 'admin_list_resources' 

2658 """ 

2659 LOGGER.debug(f"User {get_user_email(user)} requested resource list (page={page}, per_page={per_page})") 

2660 user_email = get_user_email(user) 

2661 

2662 # Call resource_service.list_resources with page-based pagination 

2663 paginated_result = await resource_service.list_resources( 

2664 db=db, 

2665 include_inactive=include_inactive, 

2666 page=page, 

2667 per_page=per_page, 

2668 user_email=user_email, 

2669 ) 

2670 

2671 # Return standardized paginated response 

2672 return { 

2673 "data": [resource.model_dump(by_alias=True) for resource in paginated_result["data"]], 

2674 "pagination": paginated_result["pagination"].model_dump(), 

2675 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

2676 } 

2677 

2678 

2679@admin_router.get("/prompts", response_model=PaginatedResponse) 

2680@require_permission("prompts.read", allow_admin_bypass=False) 

2681async def admin_list_prompts( 

2682 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

2683 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

2684 include_inactive: bool = False, 

2685 db: Session = Depends(get_db), 

2686 user=Depends(get_current_user_with_permissions), 

2687) -> Dict[str, Any]: 

2688 """ 

2689 List prompts for the admin UI with pagination support. 

2690 

2691 This endpoint retrieves a paginated list of prompts from the database, optionally 

2692 including those that are inactive. Uses offset-based (page/per_page) pagination. 

2693 

2694 Args: 

2695 page (int): Page number (1-indexed) for offset pagination. 

2696 per_page (int): Number of items per page. 

2697 include_inactive (bool): Whether to include inactive prompts in the results. 

2698 db (Session): Database session dependency. 

2699 user (str): Authenticated user dependency. 

2700 

2701 Returns: 

2702 Dict[str, Any]: A dictionary containing: 

2703 - data: List of prompt records formatted with by_alias=True 

2704 - pagination: Pagination metadata 

2705 - links: Pagination links (optional) 

2706 

2707 Examples: 

2708 >>> callable(admin_list_prompts) 

2709 True 

2710 >>> admin_list_prompts.__name__ 

2711 'admin_list_prompts' 

2712 """ 

2713 LOGGER.debug(f"User {get_user_email(user)} requested prompt list (page={page}, per_page={per_page})") 

2714 user_email = get_user_email(user) 

2715 

2716 # Call prompt_service.list_prompts with page-based pagination 

2717 paginated_result = await prompt_service.list_prompts( 

2718 db=db, 

2719 include_inactive=include_inactive, 

2720 page=page, 

2721 per_page=per_page, 

2722 user_email=user_email, 

2723 ) 

2724 

2725 # Return standardized paginated response 

2726 return { 

2727 "data": [prompt.model_dump(by_alias=True) for prompt in paginated_result["data"]], 

2728 "pagination": paginated_result["pagination"].model_dump(), 

2729 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

2730 } 

2731 

2732 

2733@admin_router.get("/gateways", response_model=PaginatedResponse) 

2734@require_permission("gateways.read", allow_admin_bypass=False) 

2735async def admin_list_gateways( 

2736 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

2737 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

2738 include_inactive: bool = False, 

2739 db: Session = Depends(get_db), 

2740 user=Depends(get_current_user_with_permissions), 

2741) -> Dict[str, Any]: 

2742 """ 

2743 List gateways for the admin UI with pagination support. 

2744 

2745 This endpoint retrieves a paginated list of gateways from the database, optionally 

2746 including those that are inactive. Uses offset-based (page/per_page) pagination. 

2747 

2748 Args: 

2749 page (int): Page number (1-indexed) for offset pagination. 

2750 per_page (int): Number of items per page. 

2751 include_inactive (bool): Whether to include inactive gateways in the results. 

2752 db (Session): Database session dependency. 

2753 user (str): Authenticated user dependency. 

2754 

2755 Returns: 

2756 Dict[str, Any]: A dictionary containing: 

2757 - data: List of gateway records formatted with by_alias=True 

2758 - pagination: Pagination metadata 

2759 - links: Pagination links (optional) 

2760 

2761 Examples: 

2762 >>> callable(admin_list_gateways) 

2763 True 

2764 >>> admin_list_gateways.__name__ 

2765 'admin_list_gateways' 

2766 """ 

2767 user_email = get_user_email(user) 

2768 LOGGER.debug(f"User {user_email} requested gateway list (page={page}, per_page={per_page})") 

2769 

2770 # Call gateway_service.list_gateways with page-based pagination 

2771 paginated_result = await gateway_service.list_gateways( 

2772 db=db, 

2773 include_inactive=include_inactive, 

2774 page=page, 

2775 per_page=per_page, 

2776 user_email=user_email, 

2777 ) 

2778 

2779 # Return standardized paginated response 

2780 return { 

2781 "data": [gateway.model_dump(by_alias=True) for gateway in paginated_result["data"]], 

2782 "pagination": paginated_result["pagination"].model_dump(), 

2783 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

2784 } 

2785 

2786 

2787@admin_router.post("/gateways/{gateway_id}/state") 

2788@require_permission("gateways.update", allow_admin_bypass=False) 

2789async def admin_set_gateway_state( 

2790 gateway_id: str, 

2791 request: Request, 

2792 db: Session = Depends(get_db), 

2793 user=Depends(get_current_user_with_permissions), 

2794) -> RedirectResponse: 

2795 """ 

2796 Set the active status of a gateway via the admin UI. 

2797 

2798 This endpoint allows an admin to set the active status of a gateway. 

2799 It expects a form field 'activate' with a value of "true" or "false" to 

2800 determine the new status of the gateway. 

2801 

2802 Args: 

2803 gateway_id (str): The ID of the gateway to set state for. 

2804 request (Request): The FastAPI request object containing form data. 

2805 db (Session): The database session dependency. 

2806 user (str): The authenticated user dependency. 

2807 

2808 Returns: 

2809 RedirectResponse: A redirect response to the admin dashboard with a 

2810 status code of 303 (See Other). 

2811 

2812 Examples: 

2813 >>> callable(admin_set_gateway_state) 

2814 True 

2815 >>> admin_set_gateway_state.__name__ 

2816 'admin_set_gateway_state' 

2817 """ 

2818 error_message = None 

2819 user_email = get_user_email(user) 

2820 LOGGER.debug(f"User {user_email} is setting gateway state for ID {gateway_id}") 

2821 form = await request.form() 

2822 activate = str(form.get("activate", "true")).lower() == "true" 

2823 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

2824 

2825 try: 

2826 await gateway_service.set_gateway_state(db, gateway_id, activate, user_email=user_email) 

2827 except PermissionError as e: 

2828 LOGGER.warning(f"Permission denied for user {user_email} setting gateway state {gateway_id}: {e}") 

2829 error_message = str(e) 

2830 except Exception as e: 

2831 LOGGER.error(f"Error setting gateway state: {e}") 

2832 error_message = "Failed to set gateway state. Please try again." 

2833 

2834 root_path = request.scope.get("root_path", "") 

2835 

2836 # Build redirect URL with error message if present 

2837 if error_message: 

2838 error_param = f"?error={urllib.parse.quote(error_message)}" 

2839 if is_inactive_checked.lower() == "true": 

2840 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#gateways", status_code=303) 

2841 return RedirectResponse(f"{root_path}/admin/{error_param}#gateways", status_code=303) 

2842 

2843 if is_inactive_checked.lower() == "true": 

2844 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#gateways", status_code=303) 

2845 return RedirectResponse(f"{root_path}/admin#gateways", status_code=303) 

2846 

2847 

2848@admin_router.get("/", name="admin_home", response_class=HTMLResponse) 

2849@require_permission("admin.dashboard", allow_admin_bypass=False) 

2850async def admin_ui( 

2851 request: Request, 

2852 team_id: Optional[str] = Depends(_validated_team_id_param), 

2853 include_inactive: bool = False, 

2854 db: Session = Depends(get_db), 

2855 user=Depends(get_current_user_with_permissions), 

2856 _jwt_token: str = Depends(get_jwt_token), 

2857) -> Any: 

2858 """ 

2859 Render the admin dashboard HTML page. 

2860 

2861 This endpoint serves as the main entry point to the admin UI. It fetches data for 

2862 servers, tools, resources, prompts, gateways, and roots from their respective 

2863 services, then renders the admin dashboard template with this data. 

2864 

2865 Supports optional `team_id` query param to scope the returned data to a team. 

2866 If `team_id` is provided and email-based team management is enabled, we 

2867 validate the user is a member of that team. We attempt to pass team_id into 

2868 service listing functions (preferred). If the service API does not accept a 

2869 team_id parameter we fall back to post-filtering the returned items. 

2870 

2871 The endpoint also sets a JWT token as a cookie for authentication in subsequent 

2872 requests. This token is HTTP-only for security reasons. 

2873 

2874 Args: 

2875 request (Request): FastAPI request object. 

2876 team_id (Optional[str]): Optional team ID to filter data by team. 

2877 include_inactive (bool): Whether to include inactive items in all listings. 

2878 db (Session): Database session dependency. 

2879 user (dict): Authenticated user context with permissions. 

2880 

2881 Returns: 

2882 Any: Rendered HTML template for the admin dashboard. 

2883 

2884 Examples: 

2885 >>> callable(admin_ui) 

2886 True 

2887 >>> admin_ui.__name__ 

2888 'admin_ui' 

2889 """ 

2890 LOGGER.debug(f"User {get_user_email(user)} accessed the admin UI (team_id={team_id})") 

2891 user_email = get_user_email(user) 

2892 ui_visibility_config = get_ui_visibility_config(request) 

2893 hidden_sections = set(ui_visibility_config["hidden_sections"]) 

2894 hidden_header_items = set(ui_visibility_config["hidden_header_items"]) 

2895 

2896 # -------------------------------------------------------------------------------- 

2897 # Load user teams so we can validate team_id 

2898 # -------------------------------------------------------------------------------- 

2899 user_teams = [] 

2900 team_service = None 

2901 sections_requiring_user_teams = { 

2902 "teams", 

2903 "tokens", 

2904 "users", 

2905 "tools", 

2906 "servers", 

2907 "resources", 

2908 "prompts", 

2909 "gateways", 

2910 "agents", 

2911 } 

2912 should_load_user_teams = getattr(settings, "email_auth_enabled", False) and ( 

2913 team_id is not None or "team_selector" not in hidden_header_items or bool(sections_requiring_user_teams - hidden_sections) 

2914 ) 

2915 if should_load_user_teams: 

2916 try: 

2917 team_service = TeamManagementService(db) 

2918 if user_email and "@" in user_email: 

2919 raw_teams = await team_service.get_user_teams(user_email) 

2920 

2921 # Batch fetch all data in 2 queries instead of 2N queries (N+1 elimination) 

2922 team_ids = [str(team.id) for team in raw_teams] 

2923 member_counts = await team_service.get_member_counts_batch_cached(team_ids) 

2924 user_roles = team_service.get_user_roles_batch(user_email, team_ids) 

2925 

2926 user_teams = [] 

2927 for team in raw_teams: 

2928 try: 

2929 current_team_id = str(team.id) if team.id else "" 

2930 team_dict = { 

2931 "id": current_team_id, 

2932 "name": str(team.name) if team.name else "", 

2933 "type": str(getattr(team, "type", "organization")), 

2934 "is_personal": bool(getattr(team, "is_personal", False)), 

2935 "member_count": member_counts.get(current_team_id, 0), 

2936 "role": user_roles.get(current_team_id) or "member", 

2937 } 

2938 user_teams.append(team_dict) 

2939 except Exception as team_error: 

2940 LOGGER.warning(f"Failed to serialize team {getattr(team, 'id', 'unknown')}: {team_error}") 

2941 continue 

2942 except Exception as e: 

2943 LOGGER.warning(f"Failed to load user teams: {e}") 

2944 user_teams = [] 

2945 

2946 # -------------------------------------------------------------------------------- 

2947 # Validate team_id if provided (only when email-based teams are enabled) 

2948 # If invalid, we currently *ignore* it and fall back to default behavior. 

2949 # Optionally you can raise HTTPException(403) if you prefer strict rejection. 

2950 # -------------------------------------------------------------------------------- 

2951 selected_team_id = team_id 

2952 user_email = get_user_email(user) 

2953 if team_id and getattr(settings, "email_auth_enabled", False): 

2954 # If team list failed to load for some reason, be conservative and drop selection 

2955 if not user_teams: 

2956 LOGGER.warning("team_id requested but user_teams not available; ignoring team filter") 

2957 selected_team_id = None 

2958 else: 

2959 valid_team_ids = {t["id"] for t in user_teams if t.get("id")} 

2960 if str(team_id) not in valid_team_ids: 

2961 LOGGER.warning("Requested team_id is not in user's teams; ignoring team filter (team_id=%s)", team_id) 

2962 selected_team_id = None 

2963 

2964 # -------------------------------------------------------------------------------- 

2965 # Helper: attempt to call a listing function with team_id if it supports it. 

2966 # If the method signature doesn't accept team_id, fall back to calling it without 

2967 # and then (optionally) filter the returned results. 

2968 # -------------------------------------------------------------------------------- 

2969 async def _call_list_with_team_support(method, *args, **kwargs): 

2970 """ 

2971 Attempt to call a method with an optional `team_id` parameter. 

2972 

2973 This function tries to call the given asynchronous `method` with all provided 

2974 arguments and an additional `team_id=selected_team_id`, assuming `selected_team_id` 

2975 is defined and not None. If the method does not accept a `team_id` keyword argument 

2976 (raises TypeError), the function retries the call without it. 

2977 

2978 This is useful in scenarios where some service methods optionally support team 

2979 scoping via a `team_id` parameter, but not all do. 

2980 

2981 Args: 

2982 method (Callable): The async function to be called. 

2983 *args: Positional arguments to pass to the method. 

2984 **kwargs: Keyword arguments to pass to the method. 

2985 

2986 Returns: 

2987 Any: The result of the awaited method call, typically a list of model instances. 

2988 

2989 Raises: 

2990 Any exception raised by the method itself, except TypeError when `team_id` is unsupported. 

2991 

2992 

2993 Doctest: 

2994 >>> async def sample_method(a, b): 

2995 ... return [a, b] 

2996 >>> async def sample_method_with_team(a, b, team_id=None): 

2997 ... return [a, b, team_id] 

2998 >>> selected_team_id = 42 

2999 >>> import asyncio 

3000 >>> asyncio.run(_call_list_with_team_support(sample_method_with_team, 1, 2)) 

3001 [1, 2, 42] 

3002 >>> asyncio.run(_call_list_with_team_support(sample_method, 1, 2)) 

3003 [1, 2] 

3004 

3005 Notes: 

3006 - This function depends on a global `selected_team_id` variable. 

3007 - If `selected_team_id` is None, the method is called without `team_id`. 

3008 """ 

3009 if selected_team_id is None: 

3010 return await method(*args, **kwargs) 

3011 

3012 try: 

3013 # Preferred: pass team_id to the service method if it accepts it 

3014 return await method(*args, team_id=selected_team_id, **kwargs) 

3015 except TypeError: 

3016 # The method doesn't accept team_id -> fall back to original API 

3017 LOGGER.debug("Service method %s does not accept team_id; falling back and will post-filter", getattr(method, "__name__", str(method))) 

3018 return await method(*args, **kwargs) 

3019 

3020 # Small utility to check if a returned model or dict matches the selected_team_id. 

3021 def _matches_selected_team(item, tid: str) -> bool: 

3022 """ 

3023 Determine whether the given item is associated with the specified team ID. 

3024 

3025 This function attempts to determine if the input `item` (which may be a Pydantic model, 

3026 an object with attributes, or a dictionary) is associated with the given team ID (`tid`). 

3027 It checks several common attribute names (e.g., `team_id`, `team_ids`, `teams`) to see 

3028 if any of them match the provided team ID. These fields may contain either a single ID 

3029 or a list of IDs. 

3030 

3031 If `tid` is falsy (e.g., empty string), the function returns True. 

3032 

3033 Args: 

3034 item: An object or dictionary that may contain team identification fields. 

3035 tid (str): The team ID to match. 

3036 

3037 Returns: 

3038 bool: True if the item is associated with the specified team ID, otherwise False. 

3039 

3040 Examples: 

3041 >>> class Obj: 

3042 ... team_id = 'abc123' 

3043 >>> _matches_selected_team(Obj(), 'abc123') 

3044 True 

3045 

3046 >>> class Obj: 

3047 ... team_ids = ['abc123', 'def456'] 

3048 >>> _matches_selected_team(Obj(), 'def456') 

3049 True 

3050 

3051 >>> _matches_selected_team({'teamId': 'xyz789'}, 'xyz789') 

3052 True 

3053 

3054 >>> _matches_selected_team({'teamIds': ['123', '456']}, '789') 

3055 False 

3056 

3057 >>> _matches_selected_team({'teams': ['t1', 't2']}, 't1') 

3058 True 

3059 

3060 >>> _matches_selected_team(None, 'abc') 

3061 False 

3062 """ 

3063 # If an item is explicitly public, it should be visible to any team 

3064 try: 

3065 vis = getattr(item, "visibility", None) 

3066 if vis is None and isinstance(item, dict): 

3067 vis = item.get("visibility") 

3068 if isinstance(vis, str) and vis.lower() == "public": 

3069 return True 

3070 except Exception as exc: # pragma: no cover - defensive logging for unexpected types 

3071 LOGGER.debug( 

3072 "Error checking visibility on item (type=%s): %s", 

3073 type(item), 

3074 exc, 

3075 exc_info=True, 

3076 ) 

3077 # item may be a pydantic model or dict-like 

3078 # check common fields for team membership 

3079 candidates = [] 

3080 try: 

3081 # If it's an object with attributes 

3082 candidates.extend( 

3083 [ 

3084 getattr(item, "team_id", None), 

3085 getattr(item, "teamId", None), 

3086 getattr(item, "team_ids", None), 

3087 getattr(item, "teamIds", None), 

3088 getattr(item, "teams", None), 

3089 ] 

3090 ) 

3091 except Exception: 

3092 pass # nosec B110 - Intentionally ignore errors when extracting team IDs from objects 

3093 try: 

3094 # If it's a dict-like model_dump output (we'll check keys later after model_dump) 

3095 if isinstance(item, dict): 

3096 candidates.extend( 

3097 [ 

3098 item.get("team_id"), 

3099 item.get("teamId"), 

3100 item.get("team_ids"), 

3101 item.get("teamIds"), 

3102 item.get("teams"), 

3103 ] 

3104 ) 

3105 except Exception: 

3106 pass # nosec B110 - Intentionally ignore errors when extracting team IDs from dict objects 

3107 

3108 for c in candidates: 

3109 if c is None: 

3110 continue 

3111 # Some fields may be single id or list of ids 

3112 if isinstance(c, (list, tuple, set)): 

3113 if str(tid) in [str(x) for x in c]: 

3114 return True 

3115 else: 

3116 if str(c) == str(tid): 

3117 return True 

3118 return False 

3119 

3120 # -------------------------------------------------------------------------------- 

3121 # Load each resource list using the safe _call_list_with_team_support helper. 

3122 # For each returned list, try to produce consistent "model_dump(by_alias=True)" dicts, 

3123 # applying server-side filtering as a fallback if the service didn't accept team_id. 

3124 # -------------------------------------------------------------------------------- 

3125 raw_tools = [] 

3126 if "tools" not in hidden_sections: 

3127 try: 

3128 raw_tools = await _call_list_with_team_support(tool_service.list_tools, db, include_inactive=include_inactive, user_email=user_email, limit=0) 

3129 if isinstance(raw_tools, tuple): 

3130 raw_tools = raw_tools[0] 

3131 except Exception as e: 

3132 LOGGER.exception("Failed to load tools for user: %s", e) 

3133 

3134 raw_servers = [] 

3135 if "servers" not in hidden_sections: 

3136 try: 

3137 raw_servers = await _call_list_with_team_support(server_service.list_servers, db, include_inactive=include_inactive, user_email=user_email, limit=0) 

3138 # Handle tuple return (list, cursor) 

3139 if isinstance(raw_servers, tuple): 

3140 raw_servers = raw_servers[0] 

3141 except Exception as e: 

3142 LOGGER.exception("Failed to load servers for user: %s", e) 

3143 

3144 raw_resources = [] 

3145 if "resources" not in hidden_sections: 

3146 try: 

3147 raw_resources = await _call_list_with_team_support(resource_service.list_resources, db, include_inactive=include_inactive, user_email=user_email, limit=0) 

3148 if isinstance(raw_resources, tuple): 

3149 raw_resources = raw_resources[0] 

3150 except Exception as e: 

3151 LOGGER.exception("Failed to load resources for user: %s", e) 

3152 

3153 raw_prompts = [] 

3154 if "prompts" not in hidden_sections: 

3155 try: 

3156 raw_prompts = await _call_list_with_team_support(prompt_service.list_prompts, db, include_inactive=include_inactive, user_email=user_email, limit=0) 

3157 # Handle tuple return (list, cursor) 

3158 if isinstance(raw_prompts, tuple): 

3159 raw_prompts = raw_prompts[0] 

3160 except Exception as e: 

3161 LOGGER.exception("Failed to load prompts for user: %s", e) 

3162 

3163 gateways_raw = [] 

3164 if "gateways" not in hidden_sections: 

3165 try: 

3166 gateways_raw = await _call_list_with_team_support(gateway_service.list_gateways, db, include_inactive=include_inactive, user_email=user_email, limit=0) 

3167 # Handle tuple return (list, cursor) 

3168 if isinstance(gateways_raw, tuple): 

3169 gateways_raw = gateways_raw[0] 

3170 except Exception as e: 

3171 LOGGER.exception("Failed to load gateways: %s", e) 

3172 

3173 # Convert models to dicts and filter as needed 

3174 def _to_dict_and_filter(raw_list): 

3175 """ 

3176 Convert a list of items (Pydantic models, dicts, or similar) to dictionaries and filter them 

3177 based on a globally defined `selected_team_id`. 

3178 

3179 For each item: 

3180 - Try to convert it to a dictionary via `.model_dump(by_alias=True)` (if it's a Pydantic model), 

3181 or keep it as-is if it's already a dictionary. 

3182 - If the conversion fails, try to coerce the item to a dictionary via `dict(item)`. 

3183 - If `selected_team_id` is set, include only items that match it via `_matches_selected_team`. 

3184 

3185 Args: 

3186 raw_list (list): A list of Pydantic models, dictionaries, or similar objects. 

3187 

3188 Returns: 

3189 list: A filtered list of dictionaries. 

3190 

3191 Examples: 

3192 >>> global selected_team_id 

3193 >>> selected_team_id = 'team123' 

3194 >>> class Model: 

3195 ... def __init__(self, team_id): self.team_id = team_id 

3196 ... def model_dump(self, by_alias=False): return {'team_id': self.team_id} 

3197 >>> items = [Model('team123'), Model('team999')] 

3198 >>> _to_dict_and_filter(items) 

3199 [{'team_id': 'team123'}] 

3200 

3201 >>> selected_team_id = None 

3202 >>> _to_dict_and_filter([{'team_id': 'any_team'}]) 

3203 [{'team_id': 'any_team'}] 

3204 

3205 >>> selected_team_id = 't1' 

3206 >>> _to_dict_and_filter([{'team_ids': ['t1', 't2']}, {'team_ids': ['t3']}]) 

3207 [{'team_ids': ['t1', 't2']}] 

3208 """ 

3209 out = [] 

3210 for item in raw_list or []: 

3211 try: 

3212 dumped = item.model_dump(by_alias=True) if hasattr(item, "model_dump") else (item if isinstance(item, dict) else None) 

3213 except Exception: 

3214 # if dumping failed, try to coerce to dict 

3215 try: 

3216 dumped = dict(item) if hasattr(item, "__iter__") else None 

3217 except Exception: 

3218 dumped = None 

3219 if dumped is None: 

3220 continue 

3221 

3222 # If we passed team_id to service, server-side filtering applied. 

3223 # Otherwise, filter by common team-aware fields if selected_team_id is set. 

3224 if selected_team_id: 

3225 if _matches_selected_team(item, selected_team_id) or _matches_selected_team(dumped, selected_team_id): 

3226 out.append(dumped) 

3227 else: 

3228 # skip items that don't match the selected team 

3229 continue 

3230 else: 

3231 out.append(dumped) 

3232 return out 

3233 

3234 tools = list(sorted(_to_dict_and_filter(raw_tools), key=lambda t: ((t.get("url") or "").lower(), (t.get("original_name") or "").lower()))) 

3235 servers = _to_dict_and_filter(raw_servers) 

3236 resources = _to_dict_and_filter(raw_resources) # pylint: disable=unnecessary-comprehension 

3237 prompts = _to_dict_and_filter(raw_prompts) 

3238 gateways = [g.model_dump(by_alias=True) if hasattr(g, "model_dump") else (g if isinstance(g, dict) else {}) for g in (gateways_raw or [])] 

3239 # If gateways need team filtering as dicts too, apply _to_dict_and_filter similarly: 

3240 gateways = _to_dict_and_filter(gateways_raw) if isinstance(gateways_raw, (list, tuple)) else gateways 

3241 

3242 # roots 

3243 roots = [root.model_dump(by_alias=True) for root in await root_service.list_roots()] 

3244 

3245 # Load A2A agents if enabled 

3246 a2a_agents = [] 

3247 if "agents" not in hidden_sections and a2a_service and settings.mcpgateway_a2a_enabled: 

3248 a2a_agents_raw = await a2a_service.list_agents_for_user( 

3249 db, 

3250 user_info=user_email, 

3251 include_inactive=include_inactive, 

3252 ) 

3253 a2a_agents = [agent.model_dump(by_alias=True) for agent in a2a_agents_raw] 

3254 a2a_agents = _to_dict_and_filter(a2a_agents) if isinstance(a2a_agents, (list, tuple)) else a2a_agents 

3255 

3256 # Load gRPC services if enabled and available 

3257 grpc_services = [] 

3258 try: 

3259 if "agents" not in hidden_sections and GRPC_AVAILABLE and grpc_service_mgr and settings.mcpgateway_grpc_enabled: 

3260 grpc_services_raw = await grpc_service_mgr.list_services( 

3261 db, 

3262 include_inactive=include_inactive, 

3263 user_email=user_email, 

3264 team_id=selected_team_id, 

3265 ) 

3266 grpc_services = [service.model_dump(by_alias=True) for service in grpc_services_raw] 

3267 grpc_services = _to_dict_and_filter(grpc_services) if isinstance(grpc_services, (list, tuple)) else grpc_services 

3268 except Exception as e: 

3269 LOGGER.exception("Failed to load gRPC services: %s", e) 

3270 grpc_services = [] 

3271 

3272 # Template variables and context: include selected_team_id so the template and frontend can read it 

3273 root_path = settings.app_root_path 

3274 max_name_length = settings.validation_max_name_length 

3275 

3276 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

3277 db.commit() 

3278 

3279 response = request.app.state.templates.TemplateResponse( 

3280 request, 

3281 "admin.html", 

3282 { 

3283 "request": request, 

3284 "servers": servers, 

3285 "tools": tools, 

3286 "resources": resources, 

3287 "prompts": prompts, 

3288 "gateways": gateways, 

3289 "a2a_agents": a2a_agents, 

3290 "grpc_services": grpc_services, 

3291 "roots": roots, 

3292 "include_inactive": include_inactive, 

3293 "root_path": root_path, 

3294 "max_name_length": max_name_length, 

3295 "gateway_tool_name_separator": settings.gateway_tool_name_separator, 

3296 "bulk_import_max_tools": settings.mcpgateway_bulk_import_max_tools, 

3297 "a2a_enabled": settings.mcpgateway_a2a_enabled, 

3298 "grpc_enabled": GRPC_AVAILABLE and settings.mcpgateway_grpc_enabled, 

3299 "catalog_enabled": settings.mcpgateway_catalog_enabled, 

3300 "llmchat_enabled": getattr(settings, "llmchat_enabled", False), 

3301 "toolops_enabled": getattr(settings, "toolops_enabled", False), 

3302 "observability_enabled": getattr(settings, "observability_enabled", False), 

3303 "performance_enabled": getattr(settings, "mcpgateway_performance_tracking", False), 

3304 "current_user": get_user_email(user), 

3305 "email_auth_enabled": getattr(settings, "email_auth_enabled", False), 

3306 "is_admin": bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)), 

3307 "user_teams": user_teams, 

3308 "mcpgateway_ui_tool_test_timeout": settings.mcpgateway_ui_tool_test_timeout, 

3309 "selected_team_id": selected_team_id, 

3310 "ui_airgapped": settings.mcpgateway_ui_airgapped, 

3311 "ui_hidden_sections": ui_visibility_config["hidden_sections"], 

3312 "ui_hidden_header_items": ui_visibility_config["hidden_header_items"], 

3313 "ui_hidden_tabs": ui_visibility_config["hidden_tabs"], 

3314 # Password policy flags for frontend templates 

3315 "password_min_length": getattr(settings, "password_min_length", 8), 

3316 "password_require_uppercase": getattr(settings, "password_require_uppercase", False), 

3317 "password_require_lowercase": getattr(settings, "password_require_lowercase", False), 

3318 "password_require_numbers": getattr(settings, "password_require_numbers", False), 

3319 "password_require_special": getattr(settings, "password_require_special", False), 

3320 # Token policy flags 

3321 "require_token_expiration": getattr(settings, "require_token_expiration", True), 

3322 }, 

3323 ) 

3324 

3325 # Set JWT token cookie for HTMX requests if email auth is enabled 

3326 if getattr(settings, "email_auth_enabled", False): 

3327 try: 

3328 # JWT library is imported at top level as jwt 

3329 

3330 # Determine the admin user email 

3331 admin_email = get_user_email(user) 

3332 is_admin_flag = bool(user.get("is_admin") if isinstance(user, dict) else True) 

3333 full_name = getattr(settings, "platform_admin_full_name", "Platform User") 

3334 if isinstance(user, dict): 

3335 full_name = user.get("full_name") or full_name 

3336 else: 

3337 full_name = getattr(user, "full_name", full_name) or full_name 

3338 

3339 # Preserve auth provider across admin UI token refreshes so logout behavior 

3340 # can reliably detect SSO sessions (e.g., Keycloak) later. 

3341 auth_provider = "local" 

3342 if isinstance(user, dict): 

3343 provider_from_user = user.get("auth_provider") 

3344 if isinstance(provider_from_user, str) and provider_from_user.strip(): 

3345 auth_provider = provider_from_user.strip() 

3346 else: 

3347 provider_from_user = getattr(user, "auth_provider", None) 

3348 if isinstance(provider_from_user, str) and provider_from_user.strip(): 

3349 auth_provider = provider_from_user.strip() 

3350 

3351 # get_current_user_with_permissions may not include auth_provider in its dict. 

3352 # Fall back to the current jwt_token cookie payload before refreshing it. 

3353 if auth_provider == "local": 

3354 jwt_cookie = request.cookies.get("jwt_token") 

3355 if isinstance(jwt_cookie, str) and jwt_cookie: 

3356 try: 

3357 existing_payload = await verify_jwt_token_cached(jwt_cookie, request) 

3358 existing_user = existing_payload.get("user") 

3359 provider_from_token = existing_user.get("auth_provider") if isinstance(existing_user, dict) else None 

3360 if not provider_from_token: 

3361 provider_from_token = existing_payload.get("auth_provider") 

3362 if isinstance(provider_from_token, str) and provider_from_token.strip(): 

3363 auth_provider = provider_from_token.strip() 

3364 except Exception as provider_error: # nosec B110 - best-effort provider preservation 

3365 LOGGER.warning("Could not resolve auth_provider from existing JWT cookie; SSO logout may not function correctly: %s", provider_error) 

3366 if settings.sso_keycloak_enabled: 

3367 auth_provider = "keycloak" 

3368 

3369 # Generate a lightweight session JWT token 

3370 now = datetime.now(timezone.utc) 

3371 payload = { 

3372 "sub": admin_email, 

3373 "iss": settings.jwt_issuer, 

3374 "aud": settings.jwt_audience, 

3375 "iat": int(now.timestamp()), 

3376 "exp": int((now + timedelta(minutes=settings.token_expiry)).timestamp()), 

3377 "jti": str(uuid.uuid4()), 

3378 "auth_provider": auth_provider, 

3379 "user": {"email": admin_email, "full_name": full_name, "is_admin": is_admin_flag, "auth_provider": auth_provider}, 

3380 "token_use": "session", # nosec B105 - token type marker, not a password 

3381 "scopes": {"server_id": None, "permissions": ["*"] if is_admin_flag else [], "ip_restrictions": [], "time_restrictions": {}}, 

3382 } 

3383 

3384 # Generate token using centralized token creation 

3385 token = await create_jwt_token(payload) 

3386 

3387 # Set HTTP-only cookie using centralized security cookie utility 

3388 set_auth_cookie(response, token, remember_me=False) 

3389 LOGGER.debug(f"Set session JWT token cookie for user: {admin_email}") 

3390 except Exception as e: 

3391 LOGGER.warning(f"Failed to set JWT token cookie for user {user}: {e}") 

3392 

3393 cookie_action = ui_visibility_config.get("cookie_action") 

3394 if cookie_action: 

3395 scope_root_path = request.scope.get("root_path", "") or "" 

3396 ui_cookie_path = f"{scope_root_path}/admin" if scope_root_path else "/admin" 

3397 use_secure = (settings.environment == "production") or settings.secure_cookies 

3398 samesite = settings.cookie_samesite 

3399 if cookie_action == "set": 

3400 response.set_cookie( 

3401 key=UI_HIDE_SECTIONS_COOKIE_NAME, 

3402 value=ui_visibility_config.get("cookie_value", ""), 

3403 max_age=UI_HIDE_SECTIONS_COOKIE_MAX_AGE, 

3404 path=ui_cookie_path, 

3405 httponly=True, 

3406 secure=use_secure, 

3407 samesite=samesite, 

3408 ) 

3409 elif cookie_action == "delete": 

3410 response.delete_cookie( 

3411 key=UI_HIDE_SECTIONS_COOKIE_NAME, 

3412 path=ui_cookie_path, 

3413 secure=use_secure, 

3414 httponly=True, 

3415 samesite=samesite, 

3416 ) 

3417 

3418 return response 

3419 

3420 

3421@admin_router.get("/login") 

3422async def admin_login_page(request: Request) -> Response: 

3423 """ 

3424 Render the admin login page. 

3425 

3426 This endpoint serves the login form for email-based authentication. 

3427 If email auth is disabled, redirects to the main admin page. 

3428 

3429 Args: 

3430 request (Request): FastAPI request object. 

3431 

3432 Returns: 

3433 Response: Rendered HTML or redirect response. 

3434 

3435 Examples: 

3436 >>> from fastapi import Request 

3437 >>> from fastapi.responses import HTMLResponse 

3438 >>> from unittest.mock import MagicMock 

3439 >>> 

3440 >>> # Mock request 

3441 >>> mock_request = MagicMock(spec=Request) 

3442 >>> mock_request.scope = {"root_path": "/test"} 

3443 >>> mock_request.app.state.templates = MagicMock() 

3444 >>> mock_response = HTMLResponse("<html>Login</html>") 

3445 >>> mock_request.app.state.templates.TemplateResponse.return_value = mock_response 

3446 >>> 

3447 >>> import asyncio 

3448 >>> async def test_login_page(): 

3449 ... response = await admin_login_page(mock_request) 

3450 ... return isinstance(response, HTMLResponse) 

3451 >>> 

3452 >>> asyncio.run(test_login_page()) 

3453 True 

3454 """ 

3455 # Check if email auth is enabled 

3456 if not getattr(settings, "email_auth_enabled", False): 

3457 root_path = request.scope.get("root_path", "") 

3458 return RedirectResponse(url=f"{root_path}/admin", status_code=303) 

3459 

3460 root_path = settings.app_root_path 

3461 

3462 # Only show secure cookie warning if there's a login error AND problematic config 

3463 secure_cookie_warning = None 

3464 if settings.secure_cookies and settings.environment == "development": 

3465 secure_cookie_warning = "Serving over HTTP with secure cookies enabled. If you have login issues, try disabling secure cookies in your configuration." 

3466 

3467 # Preserve email from failed login attempt 

3468 prefill_email = request.query_params.get("email", "") 

3469 

3470 # Use external template file 

3471 return request.app.state.templates.TemplateResponse( 

3472 request, 

3473 "login.html", 

3474 { 

3475 "request": request, 

3476 "root_path": root_path, 

3477 "secure_cookie_warning": secure_cookie_warning, 

3478 "ui_airgapped": settings.mcpgateway_ui_airgapped, 

3479 "prefill_email": prefill_email, 

3480 "password_reset_enabled": getattr(settings, "password_reset_enabled", True), 

3481 }, 

3482 ) 

3483 

3484 

3485@admin_router.post("/login") 

3486async def admin_login_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse: 

3487 """ 

3488 Handle admin login form submission. 

3489 

3490 This endpoint processes the email/password login form, authenticates the user, 

3491 sets the JWT cookie, and redirects to the admin panel or back to login with error. 

3492 

3493 Args: 

3494 request (Request): FastAPI request object. 

3495 db (Session): Database session dependency. 

3496 

3497 Returns: 

3498 RedirectResponse: Redirect to admin panel on success or login page on failure. 

3499 

3500 Examples: 

3501 >>> from fastapi import Request 

3502 >>> from fastapi.responses import RedirectResponse 

3503 >>> from unittest.mock import MagicMock, AsyncMock 

3504 >>> 

3505 >>> # Mock request with form data 

3506 >>> mock_request = MagicMock(spec=Request) 

3507 >>> mock_request.scope = {"root_path": "/test"} 

3508 >>> mock_form = {"email": "admin@example.com", "password": "changeme"} 

3509 >>> mock_request.form = AsyncMock(return_value=mock_form) 

3510 >>> 

3511 >>> mock_db = MagicMock() 

3512 >>> 

3513 >>> import asyncio 

3514 >>> async def test_login_handler(): 

3515 ... try: 

3516 ... response = await admin_login_handler(mock_request, mock_db) 

3517 ... return isinstance(response, RedirectResponse) 

3518 ... except Exception: 

3519 ... return True # Expected due to mocked dependencies 

3520 >>> 

3521 >>> asyncio.run(test_login_handler()) 

3522 True 

3523 """ 

3524 if not getattr(settings, "email_auth_enabled", False): 

3525 root_path = request.scope.get("root_path", "") 

3526 return RedirectResponse(url=f"{root_path}/admin", status_code=303) 

3527 

3528 try: 

3529 form = await request.form() 

3530 email_val = form.get("email") 

3531 password_val = form.get("password") 

3532 email = email_val if isinstance(email_val, str) else None 

3533 password = password_val if isinstance(password_val, str) else None 

3534 

3535 if not email or not password: 

3536 root_path = request.scope.get("root_path", "") 

3537 params = "error=missing_fields" 

3538 if email: 

3539 params += f"&email={urllib.parse.quote(email)}" 

3540 return RedirectResponse(url=f"{root_path}/admin/login?{params}", status_code=303) 

3541 

3542 # Authenticate using the email auth service 

3543 auth_service = EmailAuthService(db) 

3544 

3545 try: 

3546 # Authenticate user 

3547 LOGGER.debug(f"Attempting authentication for {email}") 

3548 user = await auth_service.authenticate_user(email, password) 

3549 LOGGER.debug(f"Authentication result: {user}") 

3550 

3551 if not user: 

3552 LOGGER.warning(f"Authentication failed for {email} - user is None") 

3553 root_path = request.scope.get("root_path", "") 

3554 return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303) 

3555 

3556 # Password change enforcement respects master switch and toggles 

3557 needs_password_change = False 

3558 

3559 if settings.password_change_enforcement_enabled: 

3560 # If flag is set on the user, always honor it (flag is cleared when password is changed) 

3561 if getattr(user, "password_change_required", False): 

3562 needs_password_change = True 

3563 LOGGER.debug("User %s has password_change_required flag set", email) 

3564 

3565 # Enforce expiry-based password change if configured and not already required 

3566 if not needs_password_change: 

3567 try: 

3568 pwd_changed = getattr(user, "password_changed_at", None) 

3569 if pwd_changed: 

3570 age_days = (utc_now() - pwd_changed).days 

3571 max_age = getattr(settings, "password_max_age_days", 90) 

3572 if age_days >= max_age: 

3573 needs_password_change = True 

3574 LOGGER.debug("User %s password expired (%s days >= %s)", email, age_days, max_age) 

3575 except Exception as exc: 

3576 LOGGER.debug("Failed to evaluate password age for %s: %s", email, exc) 

3577 

3578 # Detect default password on login if enabled 

3579 if getattr(settings, "detect_default_password_on_login", True): 

3580 password_service = Argon2PasswordService() 

3581 is_using_default_password = await password_service.verify_password_async(settings.default_user_password.get_secret_value(), user.password_hash) # nosec B105 

3582 if is_using_default_password: 

3583 if getattr(settings, "require_password_change_for_default_password", True): 

3584 user.password_change_required = True 

3585 needs_password_change = True 

3586 try: 

3587 db.commit() 

3588 except Exception as exc: # log commit failures 

3589 LOGGER.warning("Failed to commit password_change_required flag for %s: %s", email, exc) 

3590 else: 

3591 LOGGER.info("User %s is using default password but enforcement is disabled", email) 

3592 

3593 if needs_password_change: 

3594 LOGGER.info(f"User {email} requires password change - redirecting to change password page") 

3595 

3596 # Create temporary JWT token for password change process 

3597 token, _ = await create_access_token(user) 

3598 

3599 # Create redirect response to password change page 

3600 root_path = request.scope.get("root_path", "") 

3601 response = RedirectResponse(url=f"{root_path}/admin/change-password-required", status_code=303) 

3602 

3603 # Set JWT token as secure cookie for the password change process 

3604 try: 

3605 set_auth_cookie(response, token, remember_me=False) 

3606 except CookieTooLargeError: 

3607 root_path = request.scope.get("root_path", "") 

3608 return RedirectResponse( 

3609 url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}", 

3610 status_code=303, 

3611 ) 

3612 

3613 return response 

3614 

3615 # Create JWT token with proper audience and issuer claims 

3616 token, _ = await create_access_token(user) # expires_seconds not needed here 

3617 

3618 # Create redirect response 

3619 root_path = request.scope.get("root_path", "") 

3620 response = RedirectResponse(url=f"{root_path}/admin", status_code=303) 

3621 

3622 # Set JWT token as secure cookie 

3623 try: 

3624 set_auth_cookie(response, token, remember_me=False) 

3625 except CookieTooLargeError: 

3626 return RedirectResponse( 

3627 url=f"{root_path}/admin/login?error=token_too_large&email={urllib.parse.quote(email)}", 

3628 status_code=303, 

3629 ) 

3630 

3631 LOGGER.info(f"Admin user {email} logged in successfully") 

3632 return response 

3633 

3634 except Exception as e: 

3635 LOGGER.warning(f"Login failed for {email}: {e}") 

3636 

3637 if settings.secure_cookies and settings.environment == "development": 

3638 LOGGER.warning("Login failed - set SECURE_COOKIES to false in config for HTTP development") 

3639 

3640 root_path = request.scope.get("root_path", "") 

3641 return RedirectResponse(url=f"{root_path}/admin/login?error=invalid_credentials&email={urllib.parse.quote(email)}", status_code=303) 

3642 

3643 except Exception as e: 

3644 LOGGER.error(f"Login handler error: {e}") 

3645 root_path = request.scope.get("root_path", "") 

3646 return RedirectResponse(url=f"{root_path}/admin/login?error=server_error", status_code=303) 

3647 

3648 

3649@admin_router.get("/forgot-password") 

3650async def admin_forgot_password_page(request: Request) -> Response: 

3651 """Render forgot-password page. 

3652 

3653 Args: 

3654 request: Incoming HTTP request. 

3655 

3656 Returns: 

3657 Response: Forgot-password page response. 

3658 """ 

3659 root_path = settings.app_root_path 

3660 if not getattr(settings, "email_auth_enabled", False): 

3661 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) 

3662 return request.app.state.templates.TemplateResponse( 

3663 request, 

3664 "forgot-password.html", 

3665 { 

3666 "request": request, 

3667 "root_path": root_path, 

3668 "password_reset_enabled": getattr(settings, "password_reset_enabled", True), 

3669 "ui_airgapped": settings.mcpgateway_ui_airgapped, 

3670 }, 

3671 ) 

3672 

3673 

3674@admin_router.post("/forgot-password") 

3675async def admin_forgot_password_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse: 

3676 """Handle forgot-password form submission. 

3677 

3678 Args: 

3679 request: Incoming HTTP request with form data. 

3680 db: Database session dependency. 

3681 

3682 Returns: 

3683 RedirectResponse: Redirect to login or forgot-password page with status. 

3684 """ 

3685 root_path = request.scope.get("root_path", "") 

3686 if not getattr(settings, "email_auth_enabled", False): 

3687 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) 

3688 if not getattr(settings, "password_reset_enabled", True): 

3689 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303) 

3690 

3691 try: 

3692 form = await request.form() 

3693 email_val = form.get("email") 

3694 email = str(email_val).strip() if email_val else "" 

3695 if not email: 

3696 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=missing_email", status_code=303) 

3697 

3698 auth_service = EmailAuthService(db) 

3699 result = await auth_service.request_password_reset(email=email, ip_address=get_client_ip(request), user_agent=get_user_agent(request)) 

3700 if result.rate_limited: 

3701 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=rate_limited", status_code=303) 

3702 return RedirectResponse(url=f"{root_path}/admin/login?notice=reset_email_sent", status_code=303) 

3703 except Exception as exc: 

3704 LOGGER.warning("Forgot-password request failed: %s", exc) 

3705 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=server_error", status_code=303) 

3706 

3707 

3708@admin_router.get("/reset-password/{token}") 

3709async def admin_reset_password_page(token: str, request: Request, db: Session = Depends(get_db)) -> Response: 

3710 """Render password reset form for a token. 

3711 

3712 Args: 

3713 token: One-time reset token. 

3714 request: Incoming HTTP request. 

3715 db: Database session dependency. 

3716 

3717 Returns: 

3718 Response: Reset-password page response. 

3719 """ 

3720 root_path = settings.app_root_path 

3721 if not getattr(settings, "email_auth_enabled", False): 

3722 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) 

3723 if not getattr(settings, "password_reset_enabled", True): 

3724 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303) 

3725 

3726 auth_service = EmailAuthService(db) 

3727 token_valid = False 

3728 token_error = None 

3729 try: 

3730 await auth_service.validate_password_reset_token(token=token, ip_address=get_client_ip(request), user_agent=get_user_agent(request)) 

3731 token_valid = True 

3732 except AuthenticationError as exc: 

3733 token_error = str(exc) 

3734 

3735 return request.app.state.templates.TemplateResponse( 

3736 request, 

3737 "reset-password.html", 

3738 { 

3739 "request": request, 

3740 "root_path": root_path, 

3741 "token": token, 

3742 "token_valid": token_valid, 

3743 "token_error": token_error, 

3744 "password_min_length": settings.password_min_length, 

3745 "ui_airgapped": settings.mcpgateway_ui_airgapped, 

3746 }, 

3747 ) 

3748 

3749 

3750@admin_router.post("/reset-password/{token}") 

3751async def admin_reset_password_handler(token: str, request: Request, db: Session = Depends(get_db)) -> RedirectResponse: 

3752 """Handle password reset form submission. 

3753 

3754 Args: 

3755 token: One-time reset token. 

3756 request: Incoming HTTP request with reset form data. 

3757 db: Database session dependency. 

3758 

3759 Returns: 

3760 RedirectResponse: Redirect to login or reset page with status. 

3761 """ 

3762 root_path = request.scope.get("root_path", "") 

3763 if not getattr(settings, "email_auth_enabled", False): 

3764 return RedirectResponse(url=f"{root_path}/admin/login", status_code=303) 

3765 if not getattr(settings, "password_reset_enabled", True): 

3766 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=password_reset_disabled", status_code=303) 

3767 

3768 try: 

3769 form = await request.form() 

3770 password = str(form.get("password", "")) 

3771 confirm_password = str(form.get("confirm_password", "")) 

3772 if not password or not confirm_password: 

3773 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=missing_fields", status_code=303) 

3774 if password != confirm_password: 

3775 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=password_mismatch", status_code=303) 

3776 

3777 auth_service = EmailAuthService(db) 

3778 await auth_service.reset_password_with_token(token=token, new_password=password, ip_address=get_client_ip(request), user_agent=get_user_agent(request)) 

3779 return RedirectResponse(url=f"{root_path}/admin/login?notice=password_reset_success", status_code=303) 

3780 except PasswordValidationError as exc: 

3781 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error={urllib.parse.quote(str(exc))}", status_code=303) 

3782 except AuthenticationError as exc: 

3783 msg = str(exc).lower() 

3784 if "expired" in msg: 

3785 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_expired", status_code=303) 

3786 if "used" in msg: 

3787 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_used", status_code=303) 

3788 return RedirectResponse(url=f"{root_path}/admin/forgot-password?error=reset_link_invalid", status_code=303) 

3789 except Exception as exc: 

3790 LOGGER.warning("Password reset failed: %s", exc) 

3791 return RedirectResponse(url=f"{root_path}/admin/reset-password/{urllib.parse.quote(token)}?error=server_error", status_code=303) 

3792 

3793 

3794async def _admin_logout(request: Request) -> Response: 

3795 """ 

3796 Handle admin logout by clearing authentication cookies. 

3797 

3798 Supports both GET and POST methods: 

3799 - POST: User-initiated logout from the UI (redirects to login page) 

3800 - GET: OIDC front-channel logout from identity provider (returns 200 OK) 

3801 

3802 For OIDC front-channel logout, Microsoft Entra ID sends GET requests to notify 

3803 the application that the user has logged out from the IdP. The application 

3804 should clear the session and return HTTP 200. 

3805 

3806 Args: 

3807 request (Request): FastAPI request object. 

3808 

3809 Returns: 

3810 Response: RedirectResponse for POST, or Response with 200 for GET (front-channel logout). 

3811 

3812 Examples: 

3813 >>> from fastapi import Request 

3814 >>> from fastapi.responses import RedirectResponse, Response 

3815 >>> from unittest.mock import MagicMock 

3816 >>> 

3817 >>> # Mock POST request (user-initiated) 

3818 >>> mock_request = MagicMock(spec=Request) 

3819 >>> mock_request.scope = {"root_path": "/test"} 

3820 >>> mock_request.method = "POST" 

3821 >>> 

3822 >>> import asyncio 

3823 >>> async def test_logout_post(): 

3824 ... response = await _admin_logout(mock_request) 

3825 ... return isinstance(response, RedirectResponse) and response.status_code == 303 

3826 >>> 

3827 >>> asyncio.run(test_logout_post()) 

3828 True 

3829 

3830 >>> # Mock GET request (front-channel logout) 

3831 >>> mock_request.method = "GET" 

3832 >>> async def test_logout_get(): 

3833 ... response = await _admin_logout(mock_request) 

3834 ... return response.status_code == 200 

3835 >>> 

3836 >>> asyncio.run(test_logout_get()) 

3837 True 

3838 """ 

3839 

3840 async def _extract_auth_provider_from_jwt_cookie() -> Optional[str]: 

3841 """Best-effort auth provider resolution from the current JWT cookie. 

3842 

3843 Returns: 

3844 Optional[str]: Auth provider from JWT payload, if available. 

3845 """ 

3846 cookies = getattr(request, "cookies", None) 

3847 if not cookies or not hasattr(cookies, "get"): 

3848 return None 

3849 token = cookies.get("jwt_token") 

3850 if not isinstance(token, str) or not token: 

3851 return None 

3852 try: 

3853 payload = await verify_jwt_token_cached(token, request) 

3854 except Exception as exc: # nosec B110 - best-effort provider detection during logout 

3855 LOGGER.warning("Failed to verify JWT during logout - SSO session may not be cleared: %s", exc) 

3856 if settings.sso_keycloak_enabled: 

3857 return "keycloak" 

3858 return None 

3859 

3860 user_payload = payload.get("user") 

3861 if isinstance(user_payload, dict): 

3862 user_provider = user_payload.get("auth_provider") 

3863 if isinstance(user_provider, str) and user_provider: 

3864 return user_provider 

3865 

3866 auth_provider = payload.get("auth_provider") 

3867 if isinstance(auth_provider, str) and auth_provider: 

3868 return auth_provider 

3869 return None 

3870 

3871 def _build_absolute_login_url(root_path: str) -> Optional[str]: 

3872 """Build an absolute login URL using request URL, with app_domain fallback. 

3873 

3874 Args: 

3875 root_path (str): Application root path from request scope. 

3876 

3877 Returns: 

3878 Optional[str]: Absolute login URL when resolvable, otherwise ``None``. 

3879 """ 

3880 login_path = f"{root_path}/admin/login" 

3881 request_url = getattr(request, "url", None) 

3882 scheme = getattr(request_url, "scheme", None) if request_url is not None else None 

3883 netloc = getattr(request_url, "netloc", None) if request_url is not None else None 

3884 if isinstance(scheme, str) and scheme and isinstance(netloc, str) and netloc: 

3885 return f"{scheme}://{netloc}{login_path}" 

3886 

3887 app_domain = str(getattr(settings, "app_domain", "") or "").rstrip("/") 

3888 if app_domain: 

3889 return f"{app_domain}{login_path}" 

3890 return None 

3891 

3892 def _build_keycloak_logout_url(root_path: str) -> Optional[str]: 

3893 """Build Keycloak RP-initiated logout URL when all required config is available. 

3894 

3895 Args: 

3896 root_path (str): Application root path from request scope. 

3897 

3898 Returns: 

3899 Optional[str]: Keycloak logout URL when all inputs are valid, otherwise ``None``. 

3900 """ 

3901 if not settings.sso_keycloak_enabled or not settings.sso_keycloak_base_url: 

3902 return None 

3903 

3904 login_url = _build_absolute_login_url(root_path) 

3905 if not login_url: 

3906 LOGGER.warning("Cannot build Keycloak logout URL: unable to resolve absolute login URL") 

3907 return None 

3908 

3909 keycloak_base = (settings.sso_keycloak_public_base_url or settings.sso_keycloak_base_url or "").rstrip("/") 

3910 realm = str(settings.sso_keycloak_realm or "").strip() 

3911 if not keycloak_base or not realm: 

3912 LOGGER.warning("Cannot build Keycloak logout URL: missing keycloak_base or realm configuration") 

3913 return None 

3914 

3915 logout_endpoint = f"{keycloak_base}/realms/{urllib.parse.quote(realm, safe='')}/protocol/openid-connect/logout" 

3916 query_params: Dict[str, str] = { 

3917 "post_logout_redirect_uri": login_url, 

3918 # Legacy Keycloak compatibility 

3919 "redirect_uri": login_url, 

3920 } 

3921 if settings.sso_keycloak_client_id: 

3922 query_params["client_id"] = settings.sso_keycloak_client_id 

3923 

3924 cookies = getattr(request, "cookies", None) 

3925 if cookies and hasattr(cookies, "get"): 

3926 id_token_hint = cookies.get("sso_id_token_hint") 

3927 if isinstance(id_token_hint, str) and id_token_hint: 

3928 # Only include the hint if the id_token has not expired. 

3929 # Keycloak rejects expired id_token_hint with an error page. 

3930 try: 

3931 payload_b64 = id_token_hint.split(".")[1] 

3932 payload_b64 += "=" * (-len(payload_b64) % 4) # pad base64 

3933 claims = orjson.loads(binascii.a2b_base64(payload_b64)) 

3934 if claims.get("exp", 0) > time.time(): 

3935 query_params["id_token_hint"] = id_token_hint 

3936 else: 

3937 LOGGER.info("Omitting expired id_token_hint from Keycloak logout URL") 

3938 except Exception: 

3939 LOGGER.debug("Could not decode id_token_hint; omitting from logout URL") 

3940 

3941 return f"{logout_endpoint}?{urllib.parse.urlencode(query_params)}" 

3942 

3943 LOGGER.info(f"Admin user logging out (method: {request.method})") 

3944 root_path = request.scope.get("root_path", "") 

3945 

3946 # For GET requests (OIDC front-channel logout), return 200 OK per OIDC spec. 

3947 if request.method == "GET": 

3948 response = Response(content="Logged out", status_code=200) 

3949 else: 

3950 response = RedirectResponse(url=f"{root_path}/admin/login", status_code=303) 

3951 

3952 auth_provider = await _extract_auth_provider_from_jwt_cookie() 

3953 if auth_provider == "keycloak": 

3954 keycloak_logout_url = _build_keycloak_logout_url(root_path) 

3955 if keycloak_logout_url: 

3956 LOGGER.info("Redirecting to Keycloak RP-initiated logout endpoint") 

3957 response = RedirectResponse(url=keycloak_logout_url, status_code=303) 

3958 

3959 # Always clear local JWT session cookie. 

3960 clear_auth_cookie(response) 

3961 use_secure = (settings.environment == "production") or settings.secure_cookies 

3962 response.delete_cookie( 

3963 key="sso_id_token_hint", 

3964 path=settings.app_root_path or "/", 

3965 secure=use_secure, 

3966 httponly=True, 

3967 samesite=settings.cookie_samesite, 

3968 ) 

3969 return response 

3970 

3971 

3972@admin_router.get("/logout", operation_id="admin_logout_get") 

3973async def admin_logout_get(request: Request) -> Response: 

3974 """GET logout endpoint for OIDC front-channel logout. 

3975 

3976 Args: 

3977 request (Request): FastAPI request object. 

3978 

3979 Returns: 

3980 Response: Logout response for front-channel requests. 

3981 """ 

3982 return await _admin_logout(request) 

3983 

3984 

3985@admin_router.post("/logout", operation_id="admin_logout_post") 

3986async def admin_logout_post(request: Request) -> Response: 

3987 """POST logout endpoint for user-initiated UI logout. 

3988 

3989 Args: 

3990 request (Request): FastAPI request object. 

3991 

3992 Returns: 

3993 Response: Logout response for UI-initiated requests. 

3994 """ 

3995 return await _admin_logout(request) 

3996 

3997 

3998@admin_router.get("/change-password-required", response_class=HTMLResponse) 

3999async def change_password_required_page(request: Request) -> HTMLResponse: 

4000 """ 

4001 Render the password change required page. 

4002 

4003 This page is shown when a user's password has expired and must be changed 

4004 to continue accessing the system. 

4005 

4006 Args: 

4007 request (Request): FastAPI request object. 

4008 

4009 Returns: 

4010 HTMLResponse: The password change required page. 

4011 

4012 Examples: 

4013 >>> from unittest.mock import MagicMock 

4014 >>> from fastapi import Request 

4015 >>> from fastapi.responses import HTMLResponse 

4016 >>> 

4017 >>> # Mock request 

4018 >>> mock_request = MagicMock(spec=Request) 

4019 >>> mock_request.scope = {"root_path": "/test"} 

4020 >>> mock_request.app.state.templates = MagicMock() 

4021 >>> mock_response = HTMLResponse("<html>Change Password</html>") 

4022 >>> mock_request.app.state.templates.TemplateResponse.return_value = mock_response 

4023 >>> 

4024 >>> import asyncio 

4025 >>> async def test_change_password_page(): 

4026 ... # Note: This requires email_auth_enabled=True in settings 

4027 ... return True # Simplified test due to settings dependency 

4028 >>> 

4029 >>> asyncio.run(test_change_password_page()) 

4030 True 

4031 """ 

4032 if not getattr(settings, "email_auth_enabled", False): 

4033 root_path = request.scope.get("root_path", "") 

4034 return RedirectResponse(url=f"{root_path}/admin", status_code=303) 

4035 

4036 # Get root path for template 

4037 root_path = request.scope.get("root_path", "") 

4038 

4039 return request.app.state.templates.TemplateResponse( 

4040 request, 

4041 "change-password-required.html", 

4042 { 

4043 "request": request, 

4044 "root_path": root_path, 

4045 "ui_airgapped": settings.mcpgateway_ui_airgapped, 

4046 "password_policy_enabled": getattr(settings, "password_policy_enabled", True), 

4047 "password_min_length": getattr(settings, "password_min_length", 8), 

4048 "password_require_uppercase": getattr(settings, "password_require_uppercase", False), 

4049 "password_require_lowercase": getattr(settings, "password_require_lowercase", False), 

4050 "password_require_numbers": getattr(settings, "password_require_numbers", False), 

4051 "password_require_special": getattr(settings, "password_require_special", False), 

4052 }, 

4053 ) 

4054 

4055 

4056@admin_router.post("/change-password-required") 

4057async def change_password_required_handler(request: Request, db: Session = Depends(get_db)) -> RedirectResponse: 

4058 """ 

4059 Handle password change requirement form submission. 

4060 

4061 This endpoint processes the forced password change form, validates the credentials, 

4062 changes the password, clears the password_change_required flag, and redirects to admin panel. 

4063 

4064 Args: 

4065 request (Request): FastAPI request object. 

4066 db (Session): Database session dependency. 

4067 

4068 Returns: 

4069 RedirectResponse: Redirect to admin panel on success or back to form with error. 

4070 

4071 Examples: 

4072 >>> from unittest.mock import MagicMock, AsyncMock 

4073 >>> from fastapi import Request 

4074 >>> from fastapi.responses import RedirectResponse 

4075 >>> 

4076 >>> # Mock request with form data 

4077 >>> mock_request = MagicMock(spec=Request) 

4078 >>> mock_request.scope = {"root_path": "/test"} 

4079 >>> mock_form = { 

4080 ... "current_password": "oldpass", 

4081 ... "new_password": "newpass123", 

4082 ... "confirm_password": "newpass123" 

4083 ... } 

4084 >>> mock_request.form = AsyncMock(return_value=mock_form) 

4085 >>> mock_request.cookies = {"jwt_token": "test_token"} 

4086 >>> mock_request.headers = {"User-Agent": "TestAgent"} 

4087 >>> 

4088 >>> mock_db = MagicMock() 

4089 >>> 

4090 >>> import asyncio 

4091 >>> async def test_password_change_handler(): 

4092 ... # Note: Full test requires email_auth_enabled and valid JWT 

4093 ... return True # Simplified test due to settings/auth dependencies 

4094 >>> 

4095 >>> asyncio.run(test_password_change_handler()) 

4096 True 

4097 """ 

4098 if not getattr(settings, "email_auth_enabled", False): 

4099 root_path = request.scope.get("root_path", "") 

4100 return RedirectResponse(url=f"{root_path}/admin", status_code=303) 

4101 

4102 try: 

4103 form = await request.form() 

4104 current_password_val = form.get("current_password") 

4105 new_password_val = form.get("new_password") 

4106 confirm_password_val = form.get("confirm_password") 

4107 

4108 current_password = current_password_val if isinstance(current_password_val, str) else None 

4109 new_password = new_password_val if isinstance(new_password_val, str) else None 

4110 confirm_password = confirm_password_val if isinstance(confirm_password_val, str) else None 

4111 

4112 if not all([current_password, new_password, confirm_password]): 

4113 root_path = request.scope.get("root_path", "") 

4114 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=missing_fields", status_code=303) 

4115 

4116 if new_password != confirm_password: 

4117 root_path = request.scope.get("root_path", "") 

4118 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=mismatch", status_code=303) 

4119 

4120 # Get user from JWT token in cookie 

4121 try: 

4122 jwt_token = request.cookies.get("jwt_token") 

4123 current_user = None 

4124 if jwt_token: 

4125 credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=jwt_token) 

4126 current_user = await get_current_user(credentials, request=request) 

4127 except Exception as e: 

4128 LOGGER.error(f"Authentication error: {e}") 

4129 current_user = None 

4130 

4131 if not current_user: 

4132 root_path = request.scope.get("root_path", "") 

4133 return RedirectResponse(url=f"{root_path}/admin/login?error=session_expired", status_code=303) 

4134 

4135 # Authenticate using the email auth service 

4136 auth_service = EmailAuthService(db) 

4137 ip_address = get_client_ip(request) 

4138 user_agent = get_user_agent(request) 

4139 

4140 try: 

4141 # Change password 

4142 success = await auth_service.change_password(email=current_user.email, old_password=current_password, new_password=new_password, ip_address=ip_address, user_agent=user_agent) 

4143 

4144 if success: 

4145 # Re-attach current_user to session for downstream use (e.g., get_teams() in token creation) 

4146 # Note: password_change_required is already cleared by auth_service.change_password() 

4147 # We must re-attach to ensure team claims are populated in the new JWT token. 

4148 user_email = current_user.email # Save before potential re-query 

4149 try: 

4150 # pylint: disable=import-outside-toplevel 

4151 # Third-Party 

4152 from sqlalchemy import inspect as sa_inspect 

4153 

4154 # First-Party 

4155 from mcpgateway.db import EmailUser 

4156 

4157 insp = sa_inspect(current_user) 

4158 if insp.transient or insp.detached: 

4159 current_user = db.query(EmailUser).filter(EmailUser.email == user_email).first() 

4160 if current_user is None: 

4161 LOGGER.error(f"User {user_email} not found after successful password change - possible race condition") 

4162 root_path = request.scope.get("root_path", "") 

4163 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303) 

4164 except Exception as e: 

4165 # Return early to avoid creating token with empty team claims 

4166 LOGGER.error(f"Failed to re-attach user {user_email} to session: {e} - password changed but token creation skipped") 

4167 root_path = request.scope.get("root_path", "") 

4168 return RedirectResponse(url=f"{root_path}/admin/login?message=password_changed", status_code=303) 

4169 

4170 # Create new JWT token 

4171 token, _ = await create_access_token(current_user) 

4172 

4173 # Create redirect response to admin panel 

4174 root_path = request.scope.get("root_path", "") 

4175 response = RedirectResponse(url=f"{root_path}/admin", status_code=303) 

4176 

4177 # Update JWT token cookie 

4178 try: 

4179 set_auth_cookie(response, token, remember_me=False) 

4180 except CookieTooLargeError: 

4181 return RedirectResponse( 

4182 url=f"{root_path}/admin/login?error=token_too_large", 

4183 status_code=303, 

4184 ) 

4185 

4186 LOGGER.info(f"User {current_user.email} successfully changed their expired password") 

4187 return response 

4188 

4189 root_path = request.scope.get("root_path", "") 

4190 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=change_failed", status_code=303) 

4191 

4192 except AuthenticationError: 

4193 root_path = request.scope.get("root_path", "") 

4194 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=invalid_password", status_code=303) 

4195 except PasswordValidationError as e: 

4196 LOGGER.warning(f"Password validation failed for {current_user.email}: {e}") 

4197 root_path = request.scope.get("root_path", "") 

4198 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=weak_password", status_code=303) 

4199 except Exception as e: 

4200 LOGGER.error(f"Password change failed for {current_user.email}: {e}", exc_info=True) 

4201 root_path = request.scope.get("root_path", "") 

4202 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303) 

4203 

4204 except Exception as e: 

4205 LOGGER.error(f"Password change handler error: {e}") 

4206 root_path = request.scope.get("root_path", "") 

4207 return RedirectResponse(url=f"{root_path}/admin/change-password-required?error=server_error", status_code=303) 

4208 

4209 

4210# ============================================================================ # 

4211# TEAM ADMIN ROUTES # 

4212# ============================================================================ # 

4213 

4214 

4215async def _generate_unified_teams_view(team_service, current_user, root_path): # pylint: disable=unused-argument 

4216 """Generate unified team view with relationship badges. 

4217 

4218 Args: 

4219 team_service: Service for team operations 

4220 current_user: Current authenticated user 

4221 root_path: Application root path 

4222 

4223 Returns: 

4224 HTML string containing the unified teams view 

4225 """ 

4226 # Get user's teams (owned + member) 

4227 user_teams = await team_service.get_user_teams(current_user.email) 

4228 

4229 # Get public teams user can join 

4230 public_teams = await team_service.discover_public_teams(current_user.email) 

4231 

4232 # Batch fetch ALL data upfront - 3 queries instead of 3N queries (N+1 elimination) 

4233 user_team_ids = [str(t.id) for t in user_teams] 

4234 public_team_ids = [str(t.id) for t in public_teams] 

4235 all_team_ids = user_team_ids + public_team_ids 

4236 

4237 member_counts = await team_service.get_member_counts_batch_cached(all_team_ids) 

4238 user_roles = team_service.get_user_roles_batch(current_user.email, user_team_ids) 

4239 pending_requests = team_service.get_pending_join_requests_batch(current_user.email, public_team_ids) 

4240 

4241 # Combine teams with relationship information 

4242 all_teams = [] 

4243 

4244 # Add user's teams (owned and member) 

4245 for team in user_teams: 

4246 team_id = str(team.id) 

4247 user_role = user_roles.get(team_id) 

4248 relationship = "owner" if user_role == "owner" else "member" 

4249 all_teams.append({"team": team, "relationship": relationship, "member_count": member_counts.get(team_id, 0)}) 

4250 

4251 # Add public teams user can join 

4252 for team in public_teams: 

4253 team_id = str(team.id) 

4254 pending_request = pending_requests.get(team_id) 

4255 relationship_data = {"team": team, "relationship": "join", "member_count": member_counts.get(team_id, 0), "pending_request": pending_request} 

4256 all_teams.append(relationship_data) 

4257 

4258 # Generate HTML for unified team view 

4259 teams_html = "" 

4260 for item in all_teams: 

4261 team = item["team"] 

4262 relationship = item["relationship"] 

4263 member_count = item["member_count"] 

4264 pending_request = item.get("pending_request") 

4265 

4266 # Relationship badge - special handling for personal teams 

4267 if team.is_personal: 

4268 badge_html = '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">PERSONAL</span>' 

4269 elif relationship == "owner": 

4270 badge_html = ( 

4271 '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">OWNER</span>' 

4272 ) 

4273 elif relationship == "member": 

4274 badge_html = ( 

4275 '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">MEMBER</span>' 

4276 ) 

4277 else: # join 

4278 badge_html = '<span class="relationship-badge inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300">CAN JOIN</span>' 

4279 

4280 # Visibility badge 

4281 visibility_badge = ( 

4282 f'<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">{team.visibility.upper()}</span>' 

4283 ) 

4284 

4285 # Subtitle based on relationship - special handling for personal teams 

4286 if team.is_personal: 

4287 subtitle = "Your personal team • Private workspace" 

4288 elif relationship == "owner": 

4289 subtitle = "You own this team" 

4290 elif relationship == "member": 

4291 subtitle = f"You are a member • Owner: {team.created_by}" 

4292 else: # join 

4293 subtitle = f"Public team • Owner: {team.created_by}" 

4294 

4295 # Escape team name for safe HTML attributes 

4296 safe_team_name = html.escape(team.name) 

4297 

4298 # Actions based on relationship - special handling for personal teams 

4299 actions_html = "" 

4300 if team.is_personal: 

4301 # Personal teams have no management actions - they're private workspaces 

4302 actions_html = """ 

4303 <div class="flex flex-wrap gap-2 mt-3"> 

4304 <span class="px-3 py-1 text-sm font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded-md"> 

4305 Personal workspace - no actions available 

4306 </span> 

4307 </div> 

4308 """ 

4309 elif relationship == "owner": 

4310 delete_button = f'<button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="deleteTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Delete Team</button>' 

4311 join_requests_button = ( 

4312 f'<button data-team-id="{team.id}" onclick="viewJoinRequestsSafe(this)" class="px-3 py-1 text-sm font-medium text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 border border-purple-300 dark:border-purple-600 hover:border-purple-500 dark:hover:border-purple-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">Join Requests</button>' 

4313 if team.visibility == "public" 

4314 else "" 

4315 ) 

4316 actions_html = f""" 

4317 <div class="flex flex-wrap gap-2 mt-3"> 

4318 <button data-team-id="{team.id}" onclick="manageTeamMembersSafe(this)" class="px-3 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 hover:border-blue-500 dark:hover:border-blue-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> 

4319 Manage Members 

4320 </button> 

4321 <button data-team-id="{team.id}" onclick="editTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"> 

4322 Edit Settings 

4323 </button> 

4324 {join_requests_button} 

4325 {delete_button} 

4326 </div> 

4327 """ 

4328 elif relationship == "member": 

4329 leave_button = f'<button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="leaveTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300 border border-orange-300 dark:border-orange-600 hover:border-orange-500 dark:hover:border-orange-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500">Leave Team</button>' 

4330 actions_html = f""" 

4331 <div class="flex flex-wrap gap-2 mt-3"> 

4332 {leave_button} 

4333 </div> 

4334 """ 

4335 else: # join 

4336 if pending_request: 

4337 # Show "Requested to Join [Cancel Request]" state 

4338 actions_html = f""" 

4339 <div class="flex flex-wrap gap-2 mt-3"> 

4340 <span class="px-3 py-1 text-sm font-medium text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900 rounded-md border border-yellow-300 dark:border-yellow-600"> 

4341 ⏳ Requested to Join 

4342 </span> 

4343 <button onclick="cancelJoinRequest('{team.id}', '{pending_request.id}')" class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> 

4344 Cancel Request 

4345 </button> 

4346 </div> 

4347 """ 

4348 else: 

4349 # Show "Request to Join" button 

4350 actions_html = f""" 

4351 <div class="flex flex-wrap gap-2 mt-3"> 

4352 <button data-team-id="{team.id}" data-team-name="{safe_team_name}" onclick="requestToJoinTeamSafe(this)" class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> 

4353 Request to Join 

4354 </button> 

4355 </div> 

4356 """ 

4357 

4358 # Truncated description (properly escaped) 

4359 description_text = "" 

4360 if team.description: 

4361 safe_description = html.escape(team.description) 

4362 truncated = safe_description[:80] + "..." if len(safe_description) > 80 else safe_description 

4363 description_text = f'<p class="team-description text-sm text-gray-600 dark:text-gray-400 mt-1">{truncated}</p>' 

4364 

4365 teams_html += f""" 

4366 <div class="team-card bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow" data-relationship="{relationship}"> 

4367 <div class="flex justify-between items-start mb-3"> 

4368 <div class="flex-1"> 

4369 <div class="flex items-center gap-3 mb-2"> 

4370 <h4 class="team-name text-lg font-medium text-gray-900 dark:text-white">🏢 {safe_team_name}</h4> 

4371 {badge_html} 

4372 {visibility_badge} 

4373 <span class="text-sm text-gray-500 dark:text-gray-400">{member_count} members</span> 

4374 </div> 

4375 <p class="text-sm text-gray-600 dark:text-gray-400">{subtitle}</p> 

4376 {description_text} 

4377 </div> 

4378 </div> 

4379 {actions_html} 

4380 </div> 

4381 """ 

4382 

4383 if not teams_html: 

4384 teams_html = '<div class="text-center py-12"><p class="text-gray-500 dark:text-gray-400">No teams found. Create your first team using the button above.</p></div>' 

4385 

4386 return HTMLResponse(content=teams_html) 

4387 

4388 

4389@admin_router.get("/teams/ids", response_class=JSONResponse) 

4390@require_permission("teams.read", allow_admin_bypass=False) 

4391async def admin_get_all_team_ids( 

4392 include_inactive: bool = False, 

4393 visibility: Optional[str] = Query(None, description="Filter by visibility"), 

4394 q: Optional[str] = Query(None, description="Search query"), 

4395 db: Session = Depends(get_db), 

4396 user=Depends(get_current_user_with_permissions), 

4397): 

4398 """Return all team IDs accessible to the current user. 

4399 

4400 Args: 

4401 include_inactive (bool): Whether to include inactive teams. 

4402 visibility (Optional[str]): Filter by team visibility. 

4403 q (Optional[str]): Search query string. 

4404 db (Session): Database session dependency. 

4405 user: Current authenticated user. 

4406 

4407 Returns: 

4408 JSONResponse: Dictionary with list of team IDs and count. 

4409 """ 

4410 team_service = TeamManagementService(db) 

4411 user_email = get_user_email(user) 

4412 

4413 auth_service = EmailAuthService(db) 

4414 current_user = await auth_service.get_user_by_email(user_email) 

4415 

4416 if not current_user: 

4417 return {"team_ids": [], "count": 0} 

4418 

4419 # If admin, get all teams (filtered) 

4420 # If regular user, get user teams + accessible public teams? 

4421 # For now, admin only per usage pattern? 

4422 # But tools/ids handles team_id scoping. Here we filter by teams user can see. 

4423 # get_all_team_ids supports search/visibility. 

4424 

4425 # Check admin 

4426 if current_user.is_admin: 

4427 team_ids = await team_service.get_all_team_ids(include_inactive=include_inactive, visibility_filter=visibility, include_personal=True, search_query=q) 

4428 else: 

4429 # For non-admins, get user's teams + public teams logic? 

4430 # get_user_teams gets all teams user is in. 

4431 # discover_public_teams gets public teams. 

4432 # unified search across them? 

4433 # Simpler: just reuse list_teams logic but with huge limit? 

4434 # Or, just return user's teams IDs filtering in memory (since user won't have millions of teams) 

4435 all_teams = await team_service.get_user_teams(user_email, include_personal=True) 

4436 # Apply filters 

4437 # Note: get_user_teams includes visibility/inactive implicitly? No, it returns what they are member of. 

4438 # But we might need public teams too? 

4439 # Let's align with list_teams logic. 

4440 

4441 filtered = [] 

4442 for t in all_teams: 

4443 if not include_inactive and not t.is_active: 

4444 continue 

4445 if visibility and t.visibility != visibility: 

4446 continue 

4447 if q: 

4448 if q.lower() not in t.name.lower() and q.lower() not in t.slug.lower(): 

4449 continue 

4450 filtered.append(t.id) 

4451 team_ids = filtered 

4452 

4453 return {"team_ids": team_ids, "count": len(team_ids)} 

4454 

4455 

4456@admin_router.get("/teams/search", response_class=JSONResponse) 

4457@require_permission("teams.read", allow_admin_bypass=False) 

4458async def admin_search_teams( 

4459 q: str = Query("", description="Search query"), 

4460 include_inactive: bool = False, 

4461 limit: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Max results"), 

4462 visibility: Optional[str] = Query(None, description="Filter by visibility"), 

4463 db: Session = Depends(get_db), 

4464 user=Depends(get_current_user_with_permissions), 

4465): 

4466 """Search teams by name/slug/description. 

4467 

4468 Args: 

4469 q (str): Search query string. 

4470 include_inactive (bool): Whether to include inactive teams. 

4471 limit (int): Maximum number of results to return. 

4472 visibility (Optional[str]): Filter by team visibility. 

4473 db (Session): Database session dependency. 

4474 user: Current authenticated user. 

4475 

4476 Returns: 

4477 JSONResponse: List of matching teams with basic info. 

4478 """ 

4479 search_query = _normalize_search_query(q) 

4480 team_service = TeamManagementService(db) 

4481 user_email = get_user_email(user) 

4482 

4483 auth_service = EmailAuthService(db) 

4484 current_user = await auth_service.get_user_by_email(user_email) 

4485 

4486 if not current_user: 

4487 return [] 

4488 

4489 # Use list_teams logic 

4490 # For admin: search globally 

4491 # For user: search user teams (and maybe public?) 

4492 # existing list_teams handles this via include_personal/logic? 

4493 # list_teams handles admin vs user distinction? 

4494 # Wait, list_teams in service doesn't know about user per se. It lists ALL teams based on query. 

4495 # The CALLER (admin.py) distinguishes. 

4496 

4497 if current_user.is_admin: 

4498 result = await team_service.list_teams(page=1, per_page=limit, include_inactive=include_inactive, visibility_filter=visibility, include_personal=True, search_query=search_query) 

4499 # Result is dict {data, pagination...} (since page provided) 

4500 teams = result["data"] 

4501 else: 

4502 # Non-admin search 

4503 # Reuse user team fetching 

4504 all_teams = await team_service.get_user_teams(user_email, include_personal=True) 

4505 # Filter in memory 

4506 filtered = [] 

4507 for t in all_teams: 

4508 if not include_inactive and not t.is_active: 

4509 continue 

4510 if visibility and t.visibility != visibility: 

4511 continue 

4512 if search_query: 

4513 description_text = (t.description or "").lower() 

4514 if search_query not in t.name.lower() and search_query not in t.slug.lower() and search_query not in description_text: 

4515 continue 

4516 filtered.append(t) 

4517 

4518 # Paginate manually 

4519 teams = filtered[:limit] 

4520 

4521 serialized_teams = [{"id": t.id, "name": t.name, "slug": t.slug, "description": t.description, "visibility": t.visibility, "is_active": t.is_active} for t in teams] 

4522 return serialized_teams 

4523 

4524 

4525@admin_router.get("/teams/partial") 

4526@require_permission("teams.read", allow_admin_bypass=False) 

4527async def admin_teams_partial_html( 

4528 request: Request, 

4529 page: int = Query(1, ge=1, description="Page number"), 

4530 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Items per page"), 

4531 include_inactive: bool = Query(False, description="Include inactive teams"), 

4532 visibility: Optional[str] = Query(None, description="Filter by visibility"), 

4533 render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"), 

4534 q: Optional[str] = Query(None, description="Search query"), 

4535 relationship: Optional[str] = Query(None, description="Filter by relationship: owner, member, public"), 

4536 db: Session = Depends(get_db), 

4537 user=Depends(get_current_user_with_permissions), 

4538) -> HTMLResponse: 

4539 """Return HTML partial for paginated teams list (HTMX). 

4540 

4541 Args: 

4542 request (Request): FastAPI request object. 

4543 page (int): Page number for pagination. 

4544 per_page (int): Number of items per page. 

4545 include_inactive (bool): Whether to include inactive teams. 

4546 visibility (Optional[str]): Filter by team visibility. 

4547 render (Optional[str]): Render mode, e.g., 'controls' for pagination controls only. 

4548 q (Optional[str]): Search query string. 

4549 relationship (Optional[str]): Filter by relationship: owner, member, public. 

4550 db (Session): Database session dependency. 

4551 user: Current authenticated user. 

4552 

4553 Returns: 

4554 HTMLResponse: Rendered HTML partial for teams list or pagination controls. 

4555 

4556 """ 

4557 team_service = TeamManagementService(db) 

4558 user_email = get_user_email(user) 

4559 root_path = request.scope.get("root_path", "") 

4560 

4561 # Base URL for pagination links - preserve search query and relationship filter 

4562 base_url = f"{root_path}/admin/teams/partial" 

4563 query_parts = [] 

4564 if q: 

4565 query_parts.append(f"q={urllib.parse.quote(q, safe='')}") 

4566 if relationship: 

4567 query_parts.append(f"relationship={urllib.parse.quote(relationship, safe='')}") 

4568 if query_parts: 

4569 base_url += "?" + "&".join(query_parts) 

4570 

4571 # Check permissions and get current user 

4572 auth_service = EmailAuthService(db) 

4573 current_user = await auth_service.get_user_by_email(user_email) 

4574 

4575 if not current_user: 

4576 return HTMLResponse(content='<div class="text-center py-8"><p class="text-red-500">User not found</p></div>', status_code=404) 

4577 

4578 # Get user's teams and public teams for relationship info 

4579 user_teams = await team_service.get_user_teams(user_email, include_personal=True) 

4580 user_team_ids = {str(t.id) for t in user_teams} 

4581 

4582 # Get user roles for owned/member distinction 

4583 user_roles = team_service.get_user_roles_batch(user_email, list(user_team_ids)) 

4584 

4585 # Get public teams the user can join (not already a member) 

4586 # NOTE: Limited to 500 for memory safety. Non-admin users with "public" filter 

4587 # will only see up to 500 joinable teams. For deployments with >500 public teams, 

4588 # consider implementing SQL-level pagination for non-admin users. 

4589 public_teams_limit = 500 

4590 public_teams = await team_service.discover_public_teams(user_email, limit=public_teams_limit) 

4591 public_team_ids = {str(t.id) for t in public_teams} 

4592 if len(public_teams) >= public_teams_limit: 

4593 LOGGER.warning(f"Public teams discovery hit limit of {public_teams_limit} for user {user_email}. Some teams may not be visible.") 

4594 

4595 # Get pending join requests for public teams 

4596 pending_requests = team_service.get_pending_join_requests_batch(user_email, list(public_team_ids)) 

4597 

4598 if current_user.is_admin and not relationship: 

4599 # Admin sees all teams when no relationship filter 

4600 paginated_result = await team_service.list_teams( 

4601 page=page, per_page=per_page, include_inactive=include_inactive, visibility_filter=visibility, base_url=base_url, include_personal=True, search_query=q 

4602 ) 

4603 data = paginated_result["data"] 

4604 pagination = paginated_result["pagination"] 

4605 links = paginated_result["links"] 

4606 else: 

4607 # Filter by relationship or regular user view 

4608 all_teams = [] 

4609 

4610 if relationship == "owner": 

4611 # Only teams user owns 

4612 all_teams = [t for t in user_teams if user_roles.get(str(t.id)) == "owner"] 

4613 elif relationship == "member": 

4614 # Only teams user is a member of (not owner) 

4615 all_teams = [t for t in user_teams if user_roles.get(str(t.id)) == "member"] 

4616 elif relationship == "public": 

4617 # Only public teams user can join 

4618 all_teams = list(public_teams) 

4619 else: 

4620 # All teams: user's teams + public teams they can join 

4621 all_teams = list(user_teams) + list(public_teams) 

4622 

4623 # Apply search filter 

4624 if q: 

4625 q_lower = q.lower() 

4626 all_teams = [t for t in all_teams if q_lower in t.name.lower() or q_lower in (t.slug or "").lower() or q_lower in (t.description or "").lower()] 

4627 

4628 # Apply visibility filter 

4629 if visibility: 

4630 all_teams = [t for t in all_teams if t.visibility == visibility] 

4631 

4632 if not include_inactive: 

4633 all_teams = [t for t in all_teams if t.is_active] 

4634 

4635 total = len(all_teams) 

4636 total_pages = math.ceil(total / per_page) if per_page else 1 

4637 # Clamp page to valid range (matches offset_paginate behavior) 

4638 if total_pages > 0: 

4639 page = min(page, total_pages) 

4640 start = (page - 1) * per_page 

4641 end = start + per_page 

4642 data = all_teams[start:end] 

4643 

4644 pagination = PaginationMeta(page=page, per_page=per_page, total_items=total, total_pages=total_pages, has_next=end < total, has_prev=page > 1) 

4645 links = None 

4646 

4647 if render == "controls": 

4648 # Return only pagination controls 

4649 return request.app.state.templates.TemplateResponse( 

4650 request, 

4651 "pagination_controls.html", 

4652 { 

4653 "request": request, 

4654 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(), 

4655 "links": links.model_dump() if links and not isinstance(links, dict) else links, 

4656 "root_path": root_path, 

4657 "hx_target": "#unified-teams-list", 

4658 "hx_indicator": "#teams-loading", 

4659 "query_params": {"include_inactive": include_inactive, "visibility": visibility, "q": q, "relationship": relationship}, 

4660 "base_url": base_url, 

4661 }, 

4662 ) 

4663 

4664 if render == "selector": 

4665 # Return team selector items for infinite scroll dropdown 

4666 # Add member counts for display 

4667 team_ids = [str(t.id) for t in data] 

4668 counts = await team_service.get_member_counts_batch_cached(team_ids) 

4669 for t in data: 

4670 t.member_count = counts.get(str(t.id), 0) 

4671 

4672 query_params_dict = {} 

4673 if q: 

4674 query_params_dict["q"] = q 

4675 

4676 return request.app.state.templates.TemplateResponse( 

4677 request, 

4678 "teams_selector_items.html", 

4679 { 

4680 "request": request, 

4681 "data": data, 

4682 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(), 

4683 "root_path": root_path, 

4684 "query_params": query_params_dict, 

4685 }, 

4686 ) 

4687 

4688 # Batch count members 

4689 team_ids = [str(t.id) for t in data] 

4690 counts = await team_service.get_member_counts_batch_cached(team_ids) 

4691 

4692 # Build enriched data with relationship info 

4693 enriched_data = [] 

4694 for t in data: 

4695 team_id = str(t.id) 

4696 t.member_count = counts.get(team_id, 0) 

4697 

4698 # Determine relationship 

4699 t.relationship = "none" 

4700 t.pending_request = None 

4701 if t.is_personal: 

4702 t.relationship = "personal" 

4703 elif team_id in user_team_ids: 

4704 role = user_roles.get(team_id) 

4705 t.relationship = "owner" if role == "owner" else "member" 

4706 elif current_user.is_admin: 

4707 # Admins get admin controls for teams they're not members of 

4708 t.relationship = "none" # Falls through to admin controls in template 

4709 elif team_id in public_team_ids: 

4710 t.relationship = "public" 

4711 t.pending_request = pending_requests.get(team_id) 

4712 

4713 enriched_data.append(t) 

4714 

4715 # Build query params dict for pagination controls 

4716 query_params_dict = {} 

4717 if q: 

4718 query_params_dict["q"] = q 

4719 if relationship: 

4720 query_params_dict["relationship"] = relationship 

4721 if include_inactive: 

4722 query_params_dict["include_inactive"] = "true" 

4723 if visibility: 

4724 query_params_dict["visibility"] = visibility 

4725 

4726 response = request.app.state.templates.TemplateResponse( 

4727 request, 

4728 "teams_partial.html", 

4729 { 

4730 "request": request, 

4731 "data": enriched_data, 

4732 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(), 

4733 "links": links.model_dump() if links and not isinstance(links, dict) else links, 

4734 "root_path": root_path, 

4735 "query_params": query_params_dict, 

4736 }, 

4737 ) 

4738 # Prevent nginx caching for real-time team updates 

4739 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 

4740 response.headers["Pragma"] = "no-cache" 

4741 response.headers["Expires"] = "0" 

4742 return response 

4743 

4744 

4745@admin_router.get("/teams") 

4746@require_permission("teams.read", allow_admin_bypass=False) 

4747async def admin_list_teams( 

4748 request: Request, 

4749 page: int = Query(1, ge=1, description="Page number"), 

4750 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Items per page"), 

4751 q: Optional[str] = Query(None, description="Search query"), 

4752 db: Session = Depends(get_db), 

4753 user=Depends(get_current_user_with_permissions), 

4754 unified: bool = False, 

4755) -> HTMLResponse: 

4756 """List teams for admin UI via HTMX. 

4757 

4758 Args: 

4759 request: FastAPI request object 

4760 page: Page number 

4761 per_page: Items per page 

4762 q: Search query 

4763 db: Database session 

4764 user: Authenticated admin user 

4765 unified: If True, return unified team view with relationship badges 

4766 

4767 Returns: 

4768 HTML response with teams list 

4769 

4770 Raises: 

4771 HTTPException: If email auth is disabled or user not found 

4772 """ 

4773 if not getattr(settings, "email_auth_enabled", False): 

4774 return HTMLResponse(content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. Teams feature requires email auth.</p></div>', status_code=200) 

4775 

4776 try: 

4777 auth_service = EmailAuthService(db) 

4778 team_service = TeamManagementService(db) 

4779 

4780 # Get current user 

4781 user_email = get_user_email(user) 

4782 current_user = await auth_service.get_user_by_email(user_email) 

4783 if not current_user: 

4784 return HTMLResponse(content='<div class="text-center py-8"><p class="text-red-500">User not found</p></div>', status_code=200) 

4785 

4786 root_path = request.scope.get("root_path", "") 

4787 

4788 if unified: 

4789 # Generate unified team view 

4790 return await _generate_unified_teams_view(team_service, current_user, root_path) 

4791 

4792 # Traditional admin view refactored to use partial logic 

4793 # We can reuse the logic by calling the service directly or redirecting? 

4794 # Redirection requires a round trip. Calling logic allows server-side render. 

4795 # We'll re-use the logic by calling default params. 

4796 

4797 # Call list_teams logic (similar to admin_teams_partial_html but inline) 

4798 if current_user.is_admin: 

4799 # Default first page 

4800 base_url = f"{root_path}/admin/teams/partial" 

4801 if q: 

4802 base_url += f"?q={urllib.parse.quote(q, safe='')}" 

4803 

4804 paginated_result = await team_service.list_teams(page=page, per_page=per_page, base_url=base_url, include_personal=True, search_query=q) 

4805 data = paginated_result["data"] 

4806 pagination = paginated_result["pagination"] 

4807 links = paginated_result["links"] 

4808 else: 

4809 all_teams = await team_service.get_user_teams(current_user.email, include_personal=True) 

4810 # Basic pagination for user view 

4811 total = len(all_teams) 

4812 start = (page - 1) * per_page 

4813 end = start + per_page 

4814 data = all_teams[start:end] 

4815 pagination = PaginationMeta(page=page, per_page=per_page, total_items=total, total_pages=math.ceil(total / per_page) if per_page else 1, has_next=end < total, has_prev=page > 1) 

4816 links = None 

4817 

4818 # Batch counts 

4819 team_ids = [str(t.id) for t in data] 

4820 counts = await team_service.get_member_counts_batch_cached(team_ids) 

4821 for t in data: 

4822 t.member_count = counts.get(str(t.id), 0) 

4823 

4824 # Render template 

4825 return request.app.state.templates.TemplateResponse( 

4826 request, 

4827 "teams_partial.html", 

4828 { 

4829 "request": request, 

4830 "data": data, 

4831 "pagination": pagination if isinstance(pagination, dict) else pagination.model_dump(), 

4832 "links": links.model_dump() if links and not isinstance(links, dict) else links, 

4833 "root_path": root_path, 

4834 }, 

4835 ) 

4836 

4837 except Exception as e: 

4838 LOGGER.error(f"Error listing teams for admin {user}: {e}") 

4839 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading teams: {html.escape(str(e))}</p></div>', status_code=200) 

4840 

4841 

4842@admin_router.post("/teams") 

4843@require_permission("teams.create", allow_admin_bypass=False) 

4844async def admin_create_team( 

4845 request: Request, 

4846 db: Session = Depends(get_db), 

4847 user=Depends(get_current_user_with_permissions), 

4848) -> HTMLResponse: 

4849 """Create team via admin UI form submission. 

4850 

4851 Args: 

4852 request: FastAPI request object 

4853 db: Database session 

4854 user: Authenticated admin user 

4855 

4856 Returns: 

4857 HTML response with new team or error message 

4858 

4859 Raises: 

4860 HTTPException: If email auth is disabled or validation fails 

4861 """ 

4862 if not getattr(settings, "email_auth_enabled", False): 

4863 error_content = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Email authentication is disabled</div>' 

4864 response = HTMLResponse(content=error_content, status_code=403) 

4865 return response 

4866 

4867 try: 

4868 form = await request.form() 

4869 name = form.get("name") 

4870 slug = form.get("slug") or None 

4871 description = form.get("description") or None 

4872 visibility = form.get("visibility", "private") 

4873 

4874 if not name: 

4875 response = HTMLResponse( 

4876 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Team name is required</div>', 

4877 status_code=400, 

4878 ) 

4879 return response 

4880 

4881 # Create team 

4882 # First-Party 

4883 from mcpgateway.schemas import TeamCreateRequest # pylint: disable=import-outside-toplevel 

4884 

4885 team_service = TeamManagementService(db) 

4886 

4887 team_data = TeamCreateRequest(name=name, slug=slug, description=description, visibility=visibility) 

4888 

4889 # Extract user email from user dict 

4890 user_email = get_user_email(user) 

4891 

4892 await team_service.create_team(name=team_data.name, description=team_data.description, created_by=user_email, visibility=team_data.visibility) 

4893 

4894 response = HTMLResponse(content="", status_code=201) 

4895 return response 

4896 

4897 except (ValidationError, CoreValidationError) as e: 

4898 LOGGER.warning(f"Validation error creating team: {e}") 

4899 # Extract user-friendly error message from Pydantic validation error 

4900 error_messages = [] 

4901 for error in e.errors(): 

4902 msg = error.get("msg", "Invalid value") 

4903 # Clean up common Pydantic prefixes 

4904 if msg.startswith("Value error, "): 

4905 msg = msg[13:] 

4906 error_messages.append(f"{msg}") 

4907 error_text = "; ".join(error_messages) if error_messages else "Invalid input" 

4908 response = HTMLResponse( 

4909 content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">{html.escape(error_text)}</div>', 

4910 status_code=400, 

4911 ) 

4912 return response 

4913 except IntegrityError as e: 

4914 LOGGER.error(f"Error creating team for admin {user}: {e}") 

4915 if "UNIQUE constraint failed: email_teams.slug" in str(e): 

4916 error_content = '<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">A team with this name already exists. Please choose a different name.</div>' 

4917 else: 

4918 error_content = f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Database error: {html.escape(str(e))}</div>' 

4919 response = HTMLResponse(content=error_content, status_code=400) 

4920 return response 

4921 except Exception as e: 

4922 LOGGER.error(f"Error creating team for admin {user}: {e}") 

4923 response = HTMLResponse( 

4924 content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md">Error creating team: {html.escape(str(e))}</div>', 

4925 status_code=400, 

4926 ) 

4927 return response 

4928 

4929 

4930@admin_router.get("/teams/{team_id}/members") 

4931@require_permission("teams.read", allow_admin_bypass=False) 

4932async def admin_view_team_members( 

4933 team_id: str, 

4934 request: Request, 

4935 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

4936 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

4937 db: Session = Depends(get_db), 

4938 user=Depends(get_current_user_with_permissions), 

4939) -> HTMLResponse: 

4940 """View and manage team members via admin UI (unified view). 

4941 

4942 This replaces the old separate "view members" and "add members" screens with a unified 

4943 interface that shows all users with checkboxes. Members are pre-checked and can be 

4944 unchecked to remove them. Non-members can be checked to add them. 

4945 

4946 Args: 

4947 team_id: ID of the team to view members for 

4948 request: FastAPI request object 

4949 page: Page number (1-indexed). 

4950 per_page: Items per page. 

4951 db: Database session 

4952 user: Current authenticated user context 

4953 

4954 Returns: 

4955 HTMLResponse: Rendered unified team members management view 

4956 """ 

4957 if not settings.email_auth_enabled: 

4958 response = HTMLResponse( 

4959 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Email authentication is disabled</div>', 

4960 status_code=403, 

4961 ) 

4962 response.headers["HX-Retarget"] = "#edit-team-error" 

4963 response.headers["HX-Reswap"] = "innerHTML" 

4964 return response 

4965 

4966 try: 

4967 # Get root_path from request 

4968 root_path = request.scope.get("root_path", "") 

4969 

4970 # Get current user context for logging and authorization 

4971 user_email = get_user_email(user) 

4972 LOGGER.info(f"User {user_email} viewing/managing members for team {team_id}") 

4973 

4974 # First-Party 

4975 team_service = TeamManagementService(db) 

4976 EmailAuthService(db) 

4977 

4978 # Get team details 

4979 team = await team_service.get_team_by_id(team_id) 

4980 if not team: 

4981 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

4982 

4983 # Check if current user is team owner 

4984 current_user_role = await team_service.get_user_role_in_team(user_email, team_id) 

4985 is_team_owner = current_user_role == "owner" 

4986 

4987 # Escape team name to prevent XSS 

4988 safe_team_name = html.escape(team.name) 

4989 

4990 # Build the two-section management interface with form 

4991 interface_html = f""" 

4992 <div class="mb-4"> 

4993 <div class="flex justify-between items-center mb-4"> 

4994 <h3 class="text-lg font-medium text-gray-900 dark:text-white"> 

4995 Team Members: {safe_team_name} 

4996 </h3> 

4997 <button onclick="document.getElementById('team-edit-modal').classList.add('hidden')" 

4998 class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"> 

4999 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 

5000 <path stroke-linecap="round" stroke-linejoin="round" 

5001 stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 

5002 </svg> 

5003 </button> 

5004 </div> 

5005 

5006 <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 

5007 <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"> 

5008 <h4 class="text-sm font-semibold text-gray-900 dark:text-white"> 

5009 Manage Team Members • Change roles • Add or remove members 

5010 </h4> 

5011 </div> 

5012 

5013 <form id="team-members-form-{team.id}" data-team-id="{team.id}" 

5014 hx-post="{root_path}/admin/teams/{team.id}/add-member" 

5015 hx-target="#team-edit-modal-content" 

5016 hx-swap="innerHTML" 

5017 class="px-6 py-4"> 

5018 

5019 <!-- Search box --> 

5020 <div class="mb-4"> 

5021 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Users</label> 

5022 <input 

5023 type="text" 

5024 id="user-search-{team.id}" 

5025 data-team-id="{team.id}" 

5026 placeholder="Search by name or email..." 

5027 class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white" 

5028 oninput="debouncedServerSideUserSearch('{team.id}', this.value)" 

5029 /> 

5030 </div> 

5031 

5032 <!-- Current Members Section --> 

5033 <div class="mb-6"> 

5034 <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Members</h5> 

5035 <div 

5036 id="team-members-container-{team.id}" 

5037 class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700" 

5038 data-per-page="{per_page}" 

5039 hx-get="{root_path}/admin/teams/{team.id}/members/partial?page={page}&per_page={per_page}" 

5040 hx-trigger="load delay:100ms" 

5041 hx-target="this" 

5042 hx-swap="innerHTML" 

5043 > 

5044 <!-- Current members will be loaded here via HTMX --> 

5045 </div> 

5046 </div> 

5047 

5048 <!-- Users to Add Section --> 

5049 <div class="mb-4"> 

5050 <h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Users to Add</h5> 

5051 <div 

5052 id="team-non-members-container-{team.id}" 

5053 class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700" 

5054 data-per-page="{per_page}" 

5055 hx-get="{root_path}/admin/teams/{team.id}/non-members/partial?page=1&per_page={per_page}" 

5056 hx-trigger="load delay:200ms" 

5057 hx-target="this" 

5058 hx-swap="innerHTML" 

5059 > 

5060 <!-- Non-members will be loaded here via HTMX --> 

5061 </div> 

5062 </div> 

5063 

5064 <!-- Submit button (only for team owners) --> 

5065 { 

5066 "" 

5067 if not is_team_owner 

5068 else ''' 

5069 <div class="flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700"> 

5070 <button type="submit" 

5071 class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> 

5072 Save Changes 

5073 </button> 

5074 </div> 

5075 ''' 

5076 } 

5077 </form> 

5078 </div> 

5079 </div> 

5080 """ # nosec B608 - HTML template f-string, not SQL (uses SQLAlchemy ORM for DB) 

5081 

5082 response = HTMLResponse(content=interface_html) 

5083 # Prevent nginx caching for real-time team member updates 

5084 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 

5085 response.headers["Pragma"] = "no-cache" 

5086 response.headers["Expires"] = "0" 

5087 return response 

5088 

5089 except Exception as e: 

5090 LOGGER.error(f"Error viewing team members {team_id}: {e}") 

5091 return HTMLResponse(content=f'<div class="text-red-500">Error loading members: {html.escape(str(e))}</div>', status_code=500) 

5092 

5093 

5094@admin_router.get("/teams/{team_id}/members/add") 

5095@require_permission("teams.manage_members", allow_admin_bypass=False) 

5096async def admin_add_team_members_view( 

5097 team_id: str, 

5098 request: Request, 

5099 db: Session = Depends(get_db), 

5100 user=Depends(get_current_user_with_permissions), 

5101) -> HTMLResponse: 

5102 """Show add members interface with paginated user selector. 

5103 

5104 Args: 

5105 team_id: ID of the team to add members to 

5106 request: FastAPI request object 

5107 db: Database session 

5108 user: Current authenticated user context 

5109 

5110 Returns: 

5111 HTMLResponse: Rendered add members interface 

5112 """ 

5113 if not settings.email_auth_enabled: 

5114 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5115 

5116 try: 

5117 # Get root_path from request 

5118 root_path = request.scope.get("root_path", "") 

5119 

5120 # Get current user context for logging and authorization 

5121 user_email = get_user_email(user) 

5122 LOGGER.info(f"User {user_email} adding members to team {team_id}") 

5123 

5124 # First-Party 

5125 team_service = TeamManagementService(db) 

5126 

5127 # Get team details 

5128 team = await team_service.get_team_by_id(team_id) 

5129 if not team: 

5130 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5131 

5132 # Check if current user is team owner 

5133 current_user_role = await team_service.get_user_role_in_team(user_email, team_id) 

5134 if current_user_role != "owner": 

5135 return HTMLResponse(content='<div class="text-red-500">Only team owners can add members</div>', status_code=403) 

5136 

5137 # Get current team members to exclude from selection 

5138 team_members = await team_service.get_team_members(team_id) 

5139 member_emails = {team_user.email for team_user, membership in team_members} 

5140 # Use orjson to safely serialize the list for JavaScript consumption (prevents XSS/injection) 

5141 member_emails_json = orjson.dumps(list(member_emails)).decode() # nosec B105 - JSON array of emails, not password 

5142 

5143 # Escape team name to prevent XSS 

5144 safe_team_name = html.escape(team.name) 

5145 

5146 # Build add members interface with paginated user selector 

5147 add_members_html = f""" 

5148 <div class="mb-4"> 

5149 <div class="flex justify-between items-center mb-4"> 

5150 <h3 class="text-lg font-medium text-gray-900 dark:text-white">Add Members to: {safe_team_name}</h3> 

5151 <div class="flex items-center space-x-2"> 

5152 <button onclick="loadTeamMembersView('{team.id}')" class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"> 

5153 ← Back to Members 

5154 </button> 

5155 <button onclick="document.getElementById('team-edit-modal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"> 

5156 <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 

5157 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 

5158 </svg> 

5159 </button> 

5160 </div> 

5161 </div> 

5162 

5163 <div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"> 

5164 <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"> 

5165 <h4 class="text-sm font-semibold text-gray-900 dark:text-white">Select Users to Add</h4> 

5166 </div> 

5167 

5168 <div class="px-6 py-4"> 

5169 <form id="add-members-form-{team.id}" data-team-id="{team.id}" hx-post="{root_path}/admin/teams/{team.id}/add-member" hx-target="#team-edit-modal-content" hx-swap="innerHTML"> 

5170 <!-- Search box --> 

5171 <div class="mb-4"> 

5172 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search Users</label> 

5173 <input 

5174 type="text" 

5175 id="user-search-{team.id}" 

5176 data-team-id="{team.id}" 

5177 data-search-url="{root_path}/admin/users/search" 

5178 data-search-limit="10" 

5179 placeholder="Search by name or email..." 

5180 class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 text-gray-900 dark:text-white" 

5181 autocomplete="off" 

5182 /> 

5183 <div id="user-search-loading-{team.id}" class="mt-2 text-sm text-gray-500 dark:text-gray-400 hidden">Searching...</div> 

5184 <div id="user-search-results-{team.id}" data-member-emails="{html.escape(member_emails_json)}" class="mt-2"></div> 

5185 </div> 

5186 

5187 <!-- User selector with infinite scroll --> 

5188 <div class="mb-4"> 

5189 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Users</label> 

5190 <div 

5191 id="user-selector-container-{team.id}" 

5192 class="border border-gray-300 dark:border-gray-600 rounded-md p-3 max-h-64 overflow-y-auto dark:bg-gray-700" 

5193 hx-get="{root_path}/admin/users/partial?page=1&per_page=20&render=selector&team_id={team.id}" 

5194 hx-trigger="load" 

5195 hx-swap="innerHTML" 

5196 hx-target="#user-selector-container-{team.id}" 

5197 > 

5198 <!-- User selector items will be loaded here via HTMX --> 

5199 </div> 

5200 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> 

5201 Note: Users already in the team will be ignored if selected. 

5202 </p> 

5203 </div> 

5204 

5205 <!-- Action buttons --> 

5206 <div class="flex justify-between items-center"> 

5207 <div id="selected-count-{team.id}" class="text-sm text-gray-600 dark:text-gray-400"> 

5208 No users selected 

5209 </div> 

5210 <button 

5211 type="submit" 

5212 class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200" 

5213 > 

5214 Add Selected Members 

5215 </button> 

5216 </div> 

5217 </form> 

5218 </div> 

5219 </div> 

5220 </div> 

5221 """ # nosec B608 - HTML template f-string, not SQL (uses SQLAlchemy ORM for DB) 

5222 

5223 return HTMLResponse(content=add_members_html) 

5224 

5225 except Exception as e: 

5226 LOGGER.error(f"Error loading add members view for team {team_id}: {e}") 

5227 return HTMLResponse(content=f'<div class="text-red-500">Error loading add members view: {html.escape(str(e))}</div>', status_code=500) 

5228 

5229 

5230@admin_router.get("/teams/{team_id}/edit") 

5231@require_permission("teams.update", allow_admin_bypass=False) 

5232async def admin_get_team_edit( 

5233 team_id: str, 

5234 _request: Request, 

5235 db: Session = Depends(get_db), 

5236 _user=Depends(get_current_user_with_permissions), 

5237) -> HTMLResponse: 

5238 """Get team edit form via admin UI. 

5239 

5240 Args: 

5241 team_id: ID of the team to edit 

5242 db: Database session 

5243 

5244 Returns: 

5245 HTMLResponse: Rendered team edit form 

5246 """ 

5247 if not settings.email_auth_enabled: 

5248 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5249 

5250 try: 

5251 # Get root path for URL construction 

5252 root_path = _request.scope.get("root_path", "") if _request else "" 

5253 team_service = TeamManagementService(db) 

5254 

5255 team = await team_service.get_team_by_id(team_id) 

5256 if not team: 

5257 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5258 

5259 safe_team_name = html.escape(team.name, quote=True) 

5260 safe_description = html.escape(team.description or "") 

5261 edit_form = rf""" 

5262 <div class="space-y-4"> 

5263 <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Team</h3> 

5264 <div id="edit-team-error"></div> 

5265 <form method="post" action="{root_path}/admin/teams/{team_id}/update" hx-post="{root_path}/admin/teams/{team_id}/update" hx-target="#edit-team-error" hx-swap="innerHTML" class="space-y-4" data-team-validation="true"> 

5266 <div> 

5267 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label> 

5268 <input type="text" name="name" value="{safe_team_name}" required 

5269 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"> 

5270 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Letters, numbers, spaces, underscores, periods, and dashes only</p> 

5271 </div> 

5272 <div> 

5273 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</label> 

5274 <input type="text" name="slug" value="{team.slug}" readonly 

5275 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white"> 

5276 <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Slug cannot be changed</p> 

5277 </div> 

5278 <div> 

5279 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label> 

5280 <textarea name="description" rows="3" 

5281 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white">{safe_description}</textarea> 

5282 </div> 

5283 <div> 

5284 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Visibility</label> 

5285 <select name="visibility" 

5286 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"> 

5287 <option value="private" {"selected" if team.visibility == "private" else ""}>Private</option> 

5288 <option value="public" {"selected" if team.visibility == "public" else ""}>Public</option> 

5289 </select> 

5290 </div> 

5291 <div class="flex justify-end space-x-3"> 

5292 <button type="button" onclick="hideTeamEditModal()" 

5293 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"> 

5294 Cancel 

5295 </button> 

5296 <button type="submit" 

5297 class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> 

5298 Update Team 

5299 </button> 

5300 </div> 

5301 </form> 

5302 </div> 

5303 """ 

5304 return HTMLResponse(content=edit_form) 

5305 

5306 except Exception as e: 

5307 LOGGER.error(f"Error getting team edit form for {team_id}: {e}") 

5308 return HTMLResponse(content=f'<div class="text-red-500">Error loading team: {html.escape(str(e))}</div>', status_code=500) 

5309 

5310 

5311@admin_router.post("/teams/{team_id}/update") 

5312@require_permission("teams.update", allow_admin_bypass=False) 

5313async def admin_update_team( 

5314 team_id: str, 

5315 request: Request, 

5316 db: Session = Depends(get_db), 

5317 user=Depends(get_current_user_with_permissions), 

5318) -> Response: 

5319 """Update team via admin UI. 

5320 

5321 Args: 

5322 team_id: ID of the team to update 

5323 request: FastAPI request object 

5324 db: Database session 

5325 user: Current authenticated user context 

5326 

5327 Returns: 

5328 Response: Result of team update operation 

5329 """ 

5330 # Ensure root_path is available for URL construction in all branches 

5331 root_path = request.scope.get("root_path", "") if request else "" 

5332 

5333 if not settings.email_auth_enabled: 

5334 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5335 

5336 try: 

5337 team_service = TeamManagementService(db) 

5338 

5339 form = await request.form() 

5340 name_val = form.get("name") 

5341 desc_val = form.get("description") 

5342 vis_val = form.get("visibility", "private") 

5343 # Trim before presence check for consistent error messages 

5344 name = name_val.strip() if isinstance(name_val, str) else None 

5345 description = desc_val.strip() if isinstance(desc_val, str) and desc_val.strip() != "" else None 

5346 visibility = vis_val if isinstance(vis_val, str) else "private" 

5347 

5348 if not name: 

5349 is_htmx = request.headers.get("HX-Request") == "true" 

5350 if is_htmx: 

5351 response = HTMLResponse( 

5352 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team name is required</div>', 

5353 status_code=400, 

5354 ) 

5355 response.headers["HX-Retarget"] = "#edit-team-error" 

5356 response.headers["HX-Reswap"] = "innerHTML" 

5357 return response 

5358 error_msg = urllib.parse.quote("Team name is required") 

5359 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303) 

5360 

5361 # Validate name and description for XSS (same validation as schema) 

5362 if not re.match(settings.validation_name_pattern, name): 

5363 is_htmx = request.headers.get("HX-Request") == "true" 

5364 if is_htmx: 

5365 response = HTMLResponse( 

5366 content='<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">Team name can only contain letters, numbers, spaces, underscores, periods, and dashes</div>', 

5367 status_code=400, 

5368 ) 

5369 response.headers["HX-Retarget"] = "#edit-team-error" 

5370 response.headers["HX-Reswap"] = "innerHTML" 

5371 return response 

5372 error_msg = urllib.parse.quote("Team name contains invalid characters") 

5373 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303) 

5374 

5375 try: 

5376 SecurityValidator.validate_no_xss(name, "Team name") 

5377 if re.search(SecurityValidator.DANGEROUS_JS_PATTERN, name, re.IGNORECASE): 

5378 raise ValueError("Team name contains script patterns that may cause security issues") 

5379 if description: 

5380 SecurityValidator.validate_no_xss(description, "Team description") 

5381 if re.search(SecurityValidator.DANGEROUS_JS_PATTERN, description, re.IGNORECASE): 

5382 raise ValueError("Team description contains script patterns that may cause security issues") 

5383 except ValueError as ve: 

5384 is_htmx = request.headers.get("HX-Request") == "true" 

5385 if is_htmx: 

5386 response = HTMLResponse( 

5387 content=f'<div class="text-red-500 p-3 bg-red-50 dark:bg-red-900/20 rounded-md mb-4">{html.escape(str(ve))}</div>', 

5388 status_code=400, 

5389 ) 

5390 response.headers["HX-Retarget"] = "#edit-team-error" 

5391 response.headers["HX-Reswap"] = "innerHTML" 

5392 return response 

5393 error_msg = urllib.parse.quote(str(ve)) 

5394 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303) 

5395 

5396 # Update team 

5397 user_email = getattr(user, "email", None) or str(user) 

5398 await team_service.update_team(team_id=team_id, name=name, description=description, visibility=visibility, updated_by=user_email) 

5399 

5400 # Check if this is an HTMX request 

5401 is_htmx = request.headers.get("HX-Request") == "true" 

5402 

5403 if is_htmx: 

5404 # Return success message with auto-close and refresh for HTMX 

5405 success_html = """ 

5406 <div class="text-green-500 text-center p-4"> 

5407 <p>Team updated successfully</p> 

5408 </div> 

5409 """ 

5410 response = HTMLResponse(content=success_html) 

5411 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"closeTeamEditModal": True, "refreshUnifiedTeamsList": True, "delayMs": 1500}}).decode() 

5412 return response 

5413 # For regular form submission, redirect to admin page with teams section 

5414 return RedirectResponse(url=f"{root_path}/admin/#teams", status_code=303) 

5415 

5416 except Exception as e: 

5417 LOGGER.error(f"Error updating team {team_id}: {e}") 

5418 

5419 # Check if this is an HTMX request for error handling too 

5420 is_htmx = request.headers.get("HX-Request") == "true" 

5421 

5422 if is_htmx: 

5423 return HTMLResponse(content=f'<div class="text-red-500">Error updating team: {html.escape(str(e))}</div>', status_code=400) 

5424 # For regular form submission, redirect to admin page with error parameter 

5425 error_msg = urllib.parse.quote(f"Error updating team: {str(e)}") 

5426 return RedirectResponse(url=f"{root_path}/admin/?error={error_msg}#teams", status_code=303) 

5427 

5428 

5429@admin_router.delete("/teams/{team_id}") 

5430@require_permission("teams.delete", allow_admin_bypass=False) 

5431async def admin_delete_team( 

5432 team_id: str, 

5433 _request: Request, 

5434 db: Session = Depends(get_db), 

5435 user=Depends(get_current_user_with_permissions), 

5436) -> HTMLResponse: 

5437 """Delete team via admin UI. 

5438 

5439 Args: 

5440 team_id: ID of the team to delete 

5441 db: Database session 

5442 user: Current authenticated user context 

5443 

5444 Returns: 

5445 HTMLResponse: Success message or error response 

5446 """ 

5447 if not settings.email_auth_enabled: 

5448 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5449 

5450 try: 

5451 team_service = TeamManagementService(db) 

5452 

5453 # Get team name for success message 

5454 team = await team_service.get_team_by_id(team_id) 

5455 team_name = team.name if team else "Unknown" 

5456 

5457 # Delete team (get user email from JWT payload) 

5458 user_email = get_user_email(user) 

5459 await team_service.delete_team(team_id, deleted_by=user_email) 

5460 

5461 # Return success message with script to refresh teams list 

5462 safe_team_name = html.escape(team_name) 

5463 success_html = f""" 

5464 <div class="text-green-500 text-center p-4"> 

5465 <p>Team "{safe_team_name}" deleted successfully</p> 

5466 </div> 

5467 """ 

5468 response = HTMLResponse(content=success_html) 

5469 # Prevent nginx caching for real-time updates 

5470 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 

5471 response.headers["Pragma"] = "no-cache" 

5472 response.headers["Expires"] = "0" 

5473 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "delayMs": 1000}}).decode() 

5474 return response 

5475 

5476 except Exception as e: 

5477 LOGGER.error(f"Error deleting team {team_id}: {e}") 

5478 return HTMLResponse(content=f'<div class="text-red-500">Error deleting team: {html.escape(str(e))}</div>', status_code=400) 

5479 

5480 

5481@admin_router.post("/teams/{team_id}/add-member") 

5482@require_permission("teams.manage_members", allow_admin_bypass=False) 

5483async def admin_add_team_members( 

5484 team_id: str, 

5485 request: Request, 

5486 db: Session = Depends(get_db), 

5487 user=Depends(get_current_user_with_permissions), 

5488) -> HTMLResponse: 

5489 """Add member(s) to team via admin UI. 

5490 

5491 Supports both single user (user_email field) and multiple users (associatedUsers field). 

5492 

5493 Args: 

5494 team_id: ID of the team to add member(s) to 

5495 request: FastAPI request object 

5496 db: Database session 

5497 user: Current authenticated user context 

5498 

5499 Returns: 

5500 HTMLResponse: Success message or error response 

5501 """ 

5502 if not settings.email_auth_enabled: 

5503 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5504 

5505 try: 

5506 # First-Party 

5507 team_service = TeamManagementService(db) 

5508 auth_service = EmailAuthService(db) 

5509 

5510 # Check if team exists and validate visibility 

5511 team = await team_service.get_team_by_id(team_id) 

5512 if not team: 

5513 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5514 

5515 # For private teams, only team owners can add members directly 

5516 user_email_from_jwt = get_user_email(user) 

5517 if team.visibility == "private": 

5518 user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id) 

5519 if user_role != "owner": 

5520 return HTMLResponse(content='<div class="text-red-500">Only team owners can add members to private teams. Use the invitation system instead.</div>', status_code=403) 

5521 

5522 form = await request.form() 

5523 

5524 # Get loaded members - these are members that were visible in the form (for safe removal with pagination) 

5525 loaded_members_list = form.getlist("loadedMembers") 

5526 loaded_members = {email.strip() for email in loaded_members_list if isinstance(email, str) and email.strip()} 

5527 

5528 # Check if this is single user or multiple users 

5529 single_user_email = form.get("user_email") 

5530 multiple_user_emails = form.getlist("associatedUsers") 

5531 

5532 # Determine which mode we're in 

5533 if single_user_email: 

5534 # Single user mode (legacy form) - get single role 

5535 user_emails = [single_user_email] if isinstance(single_user_email, str) else [] 

5536 default_role = form.get("role", "member") 

5537 default_role = default_role if isinstance(default_role, str) else "member" 

5538 elif multiple_user_emails: 

5539 # Multiple users mode (new paginated selector) 

5540 seen = set() 

5541 user_emails = [] 

5542 for email in multiple_user_emails: 

5543 if not isinstance(email, str): 

5544 continue 

5545 cleaned = email.strip() 

5546 if not cleaned or cleaned in seen: 

5547 continue 

5548 seen.add(cleaned) 

5549 user_emails.append(cleaned) 

5550 default_role = "member" # Default if no per-user role specified 

5551 else: 

5552 return HTMLResponse(content='<div class="text-red-500">No users selected</div>', status_code=400) 

5553 

5554 # Get current team members 

5555 team_members = await team_service.get_team_members(team_id) 

5556 existing_member_emails = {team_user.email for team_user, membership in team_members} 

5557 

5558 # Build a map of existing member roles 

5559 existing_member_roles = {} 

5560 owner_count = team_service.count_team_owners(team_id) 

5561 for team_user, membership in team_members: 

5562 email = team_user.email 

5563 is_last_owner = membership.role == "owner" and owner_count == 1 

5564 existing_member_roles[email] = {"role": membership.role, "is_last_owner": is_last_owner} 

5565 

5566 # Track results 

5567 added = [] 

5568 updated = [] 

5569 removed = [] 

5570 errors = [] 

5571 

5572 # Process submitted users (checked boxes) 

5573 submitted_user_emails = set(user_emails) 

5574 

5575 # 1. Handle additions and updates for checked users 

5576 for user_email in user_emails: 

5577 user_email = user_email.strip() 

5578 if not user_email: 

5579 continue 

5580 

5581 try: 

5582 # Check if user exists 

5583 target_user = await auth_service.get_user_by_email(user_email) 

5584 if not target_user: 

5585 errors.append(f"{user_email} (user not found)") 

5586 continue 

5587 

5588 # Get per-user role from form (format: role_<url-encoded-email>) 

5589 encoded_email = urllib.parse.quote(user_email, safe="") 

5590 user_role_key = f"role_{encoded_email}" 

5591 user_role_val = form.get(user_role_key, default_role) 

5592 user_role = user_role_val if isinstance(user_role_val, str) else default_role 

5593 

5594 if user_email in existing_member_emails: 

5595 # User is already a member - check if role changed 

5596 current_role = existing_member_roles[user_email]["role"] 

5597 if current_role != user_role: 

5598 # Don't allow changing role of last owner 

5599 if existing_member_roles[user_email]["is_last_owner"]: 

5600 errors.append(f"{user_email} (cannot change role of last owner)") 

5601 continue 

5602 # Update role 

5603 await team_service.update_member_role(team_id=team_id, user_email=user_email, new_role=user_role, updated_by=user_email_from_jwt) 

5604 updated.append(f"{user_email} (role: {user_role})") 

5605 else: 

5606 # New member - add them 

5607 await team_service.add_member_to_team(team_id=team_id, user_email=user_email, role=user_role, invited_by=user_email_from_jwt) 

5608 added.append(user_email) 

5609 

5610 except Exception as member_error: 

5611 LOGGER.error(f"Error processing {user_email} for team {team_id}: {member_error}") 

5612 errors.append(f"{user_email} ({str(member_error)})") 

5613 

5614 # 2. Handle removals - only remove members who were LOADED in the form AND unchecked 

5615 # This prevents accidentally removing members from pages that weren't loaded yet (infinite scroll safety) 

5616 for existing_email in existing_member_emails: 

5617 # Only consider removal if the member was visible in the form (in loadedMembers) 

5618 if existing_email not in loaded_members: 

5619 continue # Member wasn't loaded in form, skip (safe for pagination) 

5620 if existing_email in submitted_user_emails: 

5621 continue # Member is checked, don't remove 

5622 

5623 member_info = existing_member_roles.get(existing_email, {}) 

5624 

5625 # Validate removal is allowed - server-side protection 

5626 # Current user cannot be removed 

5627 if existing_email == user_email_from_jwt: 

5628 errors.append(f"{existing_email} (cannot remove yourself)") 

5629 continue 

5630 # Last owner cannot be removed 

5631 if member_info.get("is_last_owner", False): 

5632 errors.append(f"{existing_email} (cannot remove last owner)") 

5633 continue 

5634 

5635 # This member was unchecked and removal is allowed - remove them 

5636 try: 

5637 await team_service.remove_member_from_team(team_id=team_id, user_email=existing_email, removed_by=user_email_from_jwt) 

5638 removed.append(existing_email) 

5639 except Exception as removal_error: 

5640 LOGGER.error(f"Error removing {existing_email} from team {team_id}: {removal_error}") 

5641 errors.append(f"{existing_email} (removal failed: {str(removal_error)})") 

5642 

5643 # Build result message 

5644 result_parts = [] 

5645 if added: 

5646 result_parts.append(f'<p class="text-green-600 dark:text-green-400">✓ Added {len(added)} member(s)</p>') 

5647 if updated: 

5648 result_parts.append(f'<p class="text-blue-600 dark:text-blue-400">↻ Updated {len(updated)} member(s)</p>') 

5649 if removed: 

5650 result_parts.append(f'<p class="text-orange-600 dark:text-orange-400">− Removed {len(removed)} member(s)</p>') 

5651 if errors: 

5652 result_parts.append(f'<p class="text-red-600 dark:text-red-400">✗ {len(errors)} error(s)</p>') 

5653 for error in errors[:5]: # Show first 5 errors 

5654 result_parts.append(f'<p class="text-xs text-red-500 dark:text-red-400 ml-4">• {error}</p>') 

5655 if len(errors) > 5: 

5656 result_parts.append(f'<p class="text-xs text-red-500 dark:text-red-400 ml-4">... and {len(errors) - 5} more</p>') 

5657 

5658 if not result_parts: 

5659 result_parts.append('<p class="text-gray-600 dark:text-gray-400">No changes made</p>') 

5660 

5661 result_html = "\n".join(result_parts) 

5662 

5663 # Return success message and close modal 

5664 success_html = f""" 

5665 <div class="text-center p-4"> 

5666 {result_html} 

5667 </div> 

5668 <script> 

5669 // Close modal after showing success message briefly 

5670 setTimeout(() => {{ 

5671 const modal = document.getElementById('team-edit-modal'); 

5672 if (modal) {{ 

5673 modal.classList.add('hidden'); 

5674 }} 

5675 }}, 1000); 

5676 </script> 

5677 """ 

5678 response = HTMLResponse(content=success_html) 

5679 

5680 # Prevent nginx caching for real-time updates 

5681 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 

5682 response.headers["Pragma"] = "no-cache" 

5683 response.headers["Expires"] = "0" 

5684 

5685 # Trigger refresh of teams list (but don't reopen modal) 

5686 response.headers["HX-Trigger"] = orjson.dumps( 

5687 { 

5688 "adminTeamAction": { 

5689 "teamId": team_id, 

5690 "refreshUnifiedTeamsList": True, 

5691 } 

5692 } 

5693 ).decode() 

5694 return response 

5695 

5696 except Exception as e: 

5697 LOGGER.error(f"Error adding member(s) to team {team_id}: {e}") 

5698 return HTMLResponse(content=f'<div class="text-red-500">Error adding member(s): {html.escape(str(e))}</div>', status_code=400) 

5699 

5700 

5701@admin_router.post("/teams/{team_id}/update-member-role") 

5702@require_permission("teams.manage_members", allow_admin_bypass=False) 

5703async def admin_update_team_member_role( 

5704 team_id: str, 

5705 request: Request, 

5706 db: Session = Depends(get_db), 

5707 user=Depends(get_current_user_with_permissions), 

5708) -> HTMLResponse: 

5709 """Update team member role via admin UI. 

5710 

5711 Args: 

5712 team_id: ID of the team containing the member 

5713 request: FastAPI request object 

5714 db: Database session 

5715 user: Current authenticated user context 

5716 

5717 Returns: 

5718 HTMLResponse: Success message or error response 

5719 """ 

5720 if not settings.email_auth_enabled: 

5721 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5722 

5723 try: 

5724 team_service = TeamManagementService(db) 

5725 

5726 # Check if team exists and validate user permissions 

5727 team = await team_service.get_team_by_id(team_id) 

5728 if not team: 

5729 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5730 

5731 # Only team owners can modify member roles 

5732 user_email_from_jwt = get_user_email(user) 

5733 user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id) 

5734 if user_role != "owner": 

5735 return HTMLResponse(content='<div class="text-red-500">Only team owners can modify member roles</div>', status_code=403) 

5736 

5737 form = await request.form() 

5738 ue_val = form.get("user_email") 

5739 nr_val = form.get("role", "member") 

5740 user_email = ue_val if isinstance(ue_val, str) else None 

5741 new_role = nr_val if isinstance(nr_val, str) else "member" 

5742 

5743 if not user_email: 

5744 return HTMLResponse(content='<div class="text-red-500">User email is required</div>', status_code=400) 

5745 

5746 if not new_role: 

5747 return HTMLResponse(content='<div class="text-red-500">Role is required</div>', status_code=400) 

5748 

5749 # Update member role 

5750 await team_service.update_member_role(team_id=team_id, user_email=user_email, new_role=new_role, updated_by=user_email_from_jwt) 

5751 

5752 # Return success message with auto-close and refresh 

5753 success_html = f""" 

5754 <div class="text-green-500 text-center p-4"> 

5755 <p>Role updated successfully for {user_email}</p> 

5756 </div> 

5757 """ 

5758 response = HTMLResponse(content=success_html) 

5759 response.headers["HX-Trigger"] = orjson.dumps( 

5760 { 

5761 "adminTeamAction": { 

5762 "teamId": team_id, 

5763 "refreshTeamMembers": True, 

5764 "refreshUnifiedTeamsList": True, 

5765 "closeRoleModal": True, 

5766 "delayMs": 1000, 

5767 } 

5768 } 

5769 ).decode() 

5770 return response 

5771 

5772 except Exception as e: 

5773 LOGGER.error(f"Error updating member role in team {team_id}: {e}") 

5774 return HTMLResponse(content=f'<div class="text-red-500">Error updating role: {html.escape(str(e))}</div>', status_code=400) 

5775 

5776 

5777@admin_router.post("/teams/{team_id}/remove-member") 

5778@require_permission("teams.manage_members", allow_admin_bypass=False) 

5779async def admin_remove_team_member( 

5780 team_id: str, 

5781 request: Request, 

5782 db: Session = Depends(get_db), 

5783 user=Depends(get_current_user_with_permissions), 

5784) -> HTMLResponse: 

5785 """Remove member from team via admin UI. 

5786 

5787 Args: 

5788 team_id: ID of the team to remove member from 

5789 request: FastAPI request object 

5790 db: Database session 

5791 user: Current authenticated user context 

5792 

5793 Returns: 

5794 HTMLResponse: Success message or error response 

5795 """ 

5796 if not settings.email_auth_enabled: 

5797 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5798 

5799 try: 

5800 team_service = TeamManagementService(db) 

5801 

5802 # Check if team exists and validate user permissions 

5803 team = await team_service.get_team_by_id(team_id) 

5804 if not team: 

5805 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5806 

5807 # Only team owners can remove members 

5808 user_email_from_jwt = get_user_email(user) 

5809 user_role = await team_service.get_user_role_in_team(user_email_from_jwt, team_id) 

5810 if user_role != "owner": 

5811 return HTMLResponse(content='<div class="text-red-500">Only team owners can remove members</div>', status_code=403) 

5812 

5813 form = await request.form() 

5814 ue_val = form.get("user_email") 

5815 user_email = ue_val if isinstance(ue_val, str) else None 

5816 

5817 if not user_email: 

5818 return HTMLResponse(content='<div class="text-red-500">User email is required</div>', status_code=400) 

5819 

5820 # Remove member from team 

5821 

5822 try: 

5823 success = await team_service.remove_member_from_team(team_id=team_id, user_email=user_email, removed_by=user_email_from_jwt) 

5824 if not success: 

5825 return HTMLResponse(content='<div class="text-red-500">Failed to remove member from team</div>', status_code=400) 

5826 except ValueError as e: 

5827 # Handle specific business logic errors (like last owner) 

5828 return HTMLResponse(content=f'<div class="text-red-500">{html.escape(str(e))}</div>', status_code=400) 

5829 

5830 # Return success message with script to refresh modal 

5831 success_html = f""" 

5832 <div class="text-green-500 text-center p-4"> 

5833 <p>Member {user_email} removed successfully</p> 

5834 </div> 

5835 """ 

5836 response = HTMLResponse(content=success_html) 

5837 response.headers["HX-Trigger"] = orjson.dumps( 

5838 { 

5839 "adminTeamAction": { 

5840 "teamId": team_id, 

5841 "refreshTeamMembers": True, 

5842 "refreshUnifiedTeamsList": True, 

5843 "delayMs": 1000, 

5844 } 

5845 } 

5846 ).decode() 

5847 return response 

5848 

5849 except Exception as e: 

5850 LOGGER.error(f"Error removing member from team {team_id}: {e}") 

5851 return HTMLResponse(content=f'<div class="text-red-500">Error removing member: {html.escape(str(e))}</div>', status_code=400) 

5852 

5853 

5854@admin_router.post("/teams/{team_id}/leave") 

5855@require_permission("teams.join", allow_admin_bypass=False) # Users who can join can also leave 

5856async def admin_leave_team( 

5857 team_id: str, 

5858 request: Request, # pylint: disable=unused-argument 

5859 db: Session = Depends(get_db), 

5860 user=Depends(get_current_user_with_permissions), 

5861) -> HTMLResponse: 

5862 """Leave a team via admin UI. 

5863 

5864 Args: 

5865 team_id: ID of the team to leave 

5866 request: FastAPI request object 

5867 db: Database session 

5868 user: Current authenticated user context 

5869 

5870 Returns: 

5871 HTMLResponse: Success message or error response 

5872 """ 

5873 if not settings.email_auth_enabled: 

5874 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5875 

5876 try: 

5877 team_service = TeamManagementService(db) 

5878 

5879 # Check if team exists 

5880 team = await team_service.get_team_by_id(team_id) 

5881 if not team: 

5882 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5883 

5884 # Get current user email 

5885 user_email = get_user_email(user) 

5886 

5887 # Check if user is a member of the team 

5888 user_role = await team_service.get_user_role_in_team(user_email, team_id) 

5889 if not user_role: 

5890 return HTMLResponse(content='<div class="text-red-500">You are not a member of this team</div>', status_code=400) 

5891 

5892 # Prevent leaving personal teams 

5893 if team.is_personal: 

5894 return HTMLResponse(content='<div class="text-red-500">Cannot leave your personal team</div>', status_code=400) 

5895 

5896 # Check if user is the last owner (use SQL COUNT instead of loading all members) 

5897 if user_role == "owner": 

5898 owner_count = team_service.count_team_owners(team_id) 

5899 if owner_count <= 1: 

5900 return HTMLResponse(content='<div class="text-red-500">Cannot leave team as the last owner. Transfer ownership or delete the team instead.</div>', status_code=400) 

5901 

5902 # Remove user from team 

5903 success = await team_service.remove_member_from_team(team_id=team_id, user_email=user_email, removed_by=user_email) 

5904 if not success: 

5905 return HTMLResponse(content='<div class="text-red-500">Failed to leave team</div>', status_code=400) 

5906 

5907 # Return success message with redirect 

5908 success_html = """ 

5909 <div class="text-green-500 text-center p-4"> 

5910 <p>Successfully left the team</p> 

5911 </div> 

5912 """ 

5913 response = HTMLResponse(content=success_html) 

5914 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "closeAllModals": True, "delayMs": 1500}}).decode() 

5915 return response 

5916 

5917 except Exception as e: 

5918 LOGGER.error(f"Error leaving team {team_id}: {e}") 

5919 return HTMLResponse(content=f'<div class="text-red-500">Error leaving team: {html.escape(str(e))}</div>', status_code=400) 

5920 

5921 

5922# ============================================================================ # 

5923# TEAM JOIN REQUEST ADMIN ROUTES # 

5924# ============================================================================ # 

5925 

5926 

5927@admin_router.post("/teams/{team_id}/join-request") 

5928@require_permission("teams.join", allow_admin_bypass=False) 

5929async def admin_create_join_request( 

5930 team_id: str, 

5931 request: Request, 

5932 db: Session = Depends(get_db), 

5933 user=Depends(get_current_user_with_permissions), 

5934) -> HTMLResponse: 

5935 """Create a join request for a team via admin UI. 

5936 

5937 Args: 

5938 team_id: ID of the team to request to join 

5939 request: FastAPI request object 

5940 db: Database session 

5941 user: Authenticated user 

5942 

5943 Returns: 

5944 HTML response with success message or error 

5945 """ 

5946 if not getattr(settings, "email_auth_enabled", False): 

5947 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

5948 

5949 try: 

5950 team_service = TeamManagementService(db) 

5951 user_email = get_user_email(user) 

5952 

5953 # Get team to verify it's public 

5954 team = await team_service.get_team_by_id(team_id) 

5955 if not team: 

5956 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

5957 

5958 if team.visibility != "public": 

5959 return HTMLResponse(content='<div class="text-red-500">Can only request to join public teams</div>', status_code=400) 

5960 

5961 # Check if user is already a member 

5962 user_role = await team_service.get_user_role_in_team(user_email, team_id) 

5963 if user_role: 

5964 return HTMLResponse(content='<div class="text-red-500">You are already a member of this team</div>', status_code=400) 

5965 

5966 # Check if user already has a pending request 

5967 existing_requests = await team_service.get_user_join_requests(user_email, team_id) 

5968 pending_request = next((req for req in existing_requests if req.status == "pending"), None) 

5969 if pending_request: 

5970 return HTMLResponse( 

5971 content=f""" 

5972 <div class="text-yellow-600"> 

5973 <p>You already have a pending request to join this team.</p> 

5974 <button onclick="cancelJoinRequest('{team_id}', '{pending_request.id}')" 

5975 class="mt-2 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> 

5976 Cancel Request 

5977 </button> 

5978 </div> 

5979 """, 

5980 status_code=200, 

5981 ) 

5982 

5983 # Get form data for optional message 

5984 form = await request.form() 

5985 msg_val = form.get("message", "") 

5986 message = msg_val if isinstance(msg_val, str) else "" 

5987 

5988 # Create join request 

5989 join_request = await team_service.create_join_request(team_id=team_id, user_email=user_email, message=message) 

5990 

5991 return HTMLResponse( 

5992 content=f""" 

5993 <div class="text-green-600"> 

5994 <p>Join request submitted successfully!</p> 

5995 <button onclick="cancelJoinRequest('{team_id}', '{join_request.id}')" 

5996 class="mt-2 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> 

5997 Cancel Request 

5998 </button> 

5999 </div> 

6000 """, 

6001 status_code=201, 

6002 ) 

6003 

6004 except Exception as e: 

6005 LOGGER.error(f"Error creating join request for team {team_id}: {e}") 

6006 return HTMLResponse(content=f'<div class="text-red-500">Error creating join request: {html.escape(str(e))}</div>', status_code=400) 

6007 

6008 

6009@admin_router.delete("/teams/{team_id}/join-request/{request_id}") 

6010@require_permission("teams.join", allow_admin_bypass=False) 

6011async def admin_cancel_join_request( 

6012 team_id: str, 

6013 request_id: str, 

6014 db: Session = Depends(get_db), 

6015 user=Depends(get_current_user_with_permissions), 

6016) -> HTMLResponse: 

6017 """Cancel a join request via admin UI. 

6018 

6019 Args: 

6020 team_id: ID of the team 

6021 request_id: ID of the join request to cancel 

6022 db: Database session 

6023 user: Authenticated user 

6024 

6025 Returns: 

6026 HTML response with updated button state 

6027 """ 

6028 if not getattr(settings, "email_auth_enabled", False): 

6029 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

6030 

6031 try: 

6032 team_service = TeamManagementService(db) 

6033 user_email = get_user_email(user) 

6034 

6035 # Cancel the join request 

6036 success = await team_service.cancel_join_request(request_id, user_email) 

6037 if not success: 

6038 return HTMLResponse(content='<div class="text-red-500">Failed to cancel join request</div>', status_code=400) 

6039 

6040 # Return the "Request to Join" button with HX-Trigger for list refresh 

6041 response = HTMLResponse( 

6042 content=f""" 

6043 <button data-team-id="{team_id}" data-team-name="Team" onclick="requestToJoinTeamSafe(this)" 

6044 class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> 

6045 Request to Join 

6046 </button> 

6047 """, 

6048 status_code=200, 

6049 ) 

6050 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"refreshUnifiedTeamsList": True, "delayMs": 1000}}).decode() 

6051 return response 

6052 

6053 except Exception as e: 

6054 LOGGER.error(f"Error canceling join request {request_id}: {e}") 

6055 return HTMLResponse(content=f'<div class="text-red-500">Error canceling join request: {html.escape(str(e))}</div>', status_code=400) 

6056 

6057 

6058@admin_router.get("/teams/{team_id}/join-requests") 

6059@require_permission("teams.manage_members", allow_admin_bypass=False) 

6060async def admin_list_join_requests( 

6061 team_id: str, 

6062 request: Request, 

6063 db: Session = Depends(get_db), 

6064 user=Depends(get_current_user_with_permissions), 

6065) -> HTMLResponse: 

6066 """List join requests for a team via admin UI. 

6067 

6068 Args: 

6069 team_id: ID of the team 

6070 request: FastAPI request object 

6071 db: Database session 

6072 user: Authenticated user 

6073 

6074 Returns: 

6075 HTML response with join requests list 

6076 """ 

6077 if not getattr(settings, "email_auth_enabled", False): 

6078 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

6079 

6080 try: 

6081 team_service = TeamManagementService(db) 

6082 user_email = get_user_email(user) 

6083 request.scope.get("root_path", "") 

6084 

6085 # Get team and verify ownership 

6086 team = await team_service.get_team_by_id(team_id) 

6087 if not team: 

6088 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

6089 

6090 user_role = await team_service.get_user_role_in_team(user_email, team_id) 

6091 if user_role != "owner": 

6092 return HTMLResponse(content='<div class="text-red-500">Only team owners can view join requests</div>', status_code=403) 

6093 

6094 # Get join requests 

6095 join_requests = await team_service.list_join_requests(team_id) 

6096 

6097 if not join_requests: 

6098 return HTMLResponse( 

6099 content=""" 

6100 <div class="text-center py-8"> 

6101 <p class="text-gray-500 dark:text-gray-400">No pending join requests</p> 

6102 </div> 

6103 """, 

6104 status_code=200, 

6105 ) 

6106 

6107 requests_html = "" 

6108 for req in join_requests: 

6109 safe_email = html.escape(req.user_email) 

6110 safe_message = html.escape(req.message) if req.message else "" 

6111 safe_status = html.escape(req.status.upper()) 

6112 requests_html += f""" 

6113 <div class="flex justify-between items-center p-4 border border-gray-200 dark:border-gray-600 rounded-lg mb-3"> 

6114 <div> 

6115 <p class="font-medium text-gray-900 dark:text-white">{safe_email}</p> 

6116 <p class="text-sm text-gray-600 dark:text-gray-400">Requested: {req.requested_at.strftime("%Y-%m-%d %H:%M") if req.requested_at else "Unknown"}</p> 

6117 {f'<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Message: {safe_message}</p>' if req.message else ""} 

6118 <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300">{safe_status}</span> 

6119 </div> 

6120 <div class="flex gap-2"> 

6121 <button onclick="approveJoinRequest('{team_id}', '{req.id}')" 

6122 class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"> 

6123 Approve 

6124 </button> 

6125 <button onclick="rejectJoinRequest('{team_id}', '{req.id}')" 

6126 class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"> 

6127 Reject 

6128 </button> 

6129 </div> 

6130 </div> 

6131 """ 

6132 

6133 safe_team_name = html.escape(team.name) 

6134 return HTMLResponse( 

6135 content=f""" 

6136 <div class="space-y-4"> 

6137 <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Join Requests for {safe_team_name}</h3> 

6138 {requests_html} 

6139 </div> 

6140 """, 

6141 status_code=200, 

6142 ) 

6143 

6144 except Exception as e: 

6145 LOGGER.error(f"Error listing join requests for team {team_id}: {e}") 

6146 return HTMLResponse(content=f'<div class="text-red-500">Error loading join requests: {html.escape(str(e))}</div>', status_code=400) 

6147 

6148 

6149@admin_router.post("/teams/{team_id}/join-requests/{request_id}/approve") 

6150@require_permission("teams.manage_members", allow_admin_bypass=False) 

6151async def admin_approve_join_request( 

6152 team_id: str, 

6153 request_id: str, 

6154 db: Session = Depends(get_db), 

6155 user=Depends(get_current_user_with_permissions), 

6156) -> HTMLResponse: 

6157 """Approve a join request via admin UI. 

6158 

6159 Args: 

6160 team_id: ID of the team 

6161 request_id: ID of the join request to approve 

6162 db: Database session 

6163 user: Authenticated user 

6164 

6165 Returns: 

6166 HTML response with success message 

6167 """ 

6168 if not getattr(settings, "email_auth_enabled", False): 

6169 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

6170 

6171 try: 

6172 team_service = TeamManagementService(db) 

6173 user_email = get_user_email(user) 

6174 

6175 # Verify team ownership 

6176 user_role = await team_service.get_user_role_in_team(user_email, team_id) 

6177 if user_role != "owner": 

6178 return HTMLResponse(content='<div class="text-red-500">Only team owners can approve join requests</div>', status_code=403) 

6179 

6180 # Approve join request 

6181 member = await team_service.approve_join_request(request_id, approved_by=user_email) 

6182 if not member: 

6183 return HTMLResponse(content='<div class="text-red-500">Join request not found</div>', status_code=404) 

6184 

6185 response = HTMLResponse( 

6186 content=f""" 

6187 <div class="text-green-600 text-center p-4"> 

6188 <p>Join request approved! {member.user_email} is now a team member.</p> 

6189 </div> 

6190 """, 

6191 status_code=200, 

6192 ) 

6193 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"teamId": team_id, "refreshJoinRequests": True, "delayMs": 1000}}).decode() 

6194 return response 

6195 

6196 except Exception as e: 

6197 LOGGER.error(f"Error approving join request {request_id}: {e}") 

6198 return HTMLResponse(content=f'<div class="text-red-500">Error approving join request: {html.escape(str(e))}</div>', status_code=400) 

6199 

6200 

6201@admin_router.post("/teams/{team_id}/join-requests/{request_id}/reject") 

6202@require_permission("teams.manage_members", allow_admin_bypass=False) 

6203async def admin_reject_join_request( 

6204 team_id: str, 

6205 request_id: str, 

6206 db: Session = Depends(get_db), 

6207 user=Depends(get_current_user_with_permissions), 

6208) -> HTMLResponse: 

6209 """Reject a join request via admin UI. 

6210 

6211 Args: 

6212 team_id: ID of the team 

6213 request_id: ID of the join request to reject 

6214 db: Database session 

6215 user: Authenticated user 

6216 

6217 Returns: 

6218 HTML response with success message 

6219 """ 

6220 if not getattr(settings, "email_auth_enabled", False): 

6221 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

6222 

6223 try: 

6224 team_service = TeamManagementService(db) 

6225 user_email = get_user_email(user) 

6226 

6227 # Verify team ownership 

6228 user_role = await team_service.get_user_role_in_team(user_email, team_id) 

6229 if user_role != "owner": 

6230 return HTMLResponse(content='<div class="text-red-500">Only team owners can reject join requests</div>', status_code=403) 

6231 

6232 # Reject join request 

6233 success = await team_service.reject_join_request(request_id, rejected_by=user_email) 

6234 if not success: 

6235 return HTMLResponse(content='<div class="text-red-500">Join request not found</div>', status_code=404) 

6236 

6237 response = HTMLResponse( 

6238 content=""" 

6239 <div class="text-green-600 text-center p-4"> 

6240 <p>Join request rejected.</p> 

6241 </div> 

6242 """, 

6243 status_code=200, 

6244 ) 

6245 response.headers["HX-Trigger"] = orjson.dumps({"adminTeamAction": {"teamId": team_id, "refreshJoinRequests": True, "delayMs": 1000}}).decode() 

6246 return response 

6247 

6248 except Exception as e: 

6249 LOGGER.error(f"Error rejecting join request {request_id}: {e}") 

6250 return HTMLResponse(content=f'<div class="text-red-500">Error rejecting join request: {html.escape(str(e))}</div>', status_code=400) 

6251 

6252 

6253# ============================================================================ # 

6254# USER MANAGEMENT ADMIN ROUTES # 

6255# ============================================================================ # 

6256 

6257 

6258def _render_user_card_html(user_obj, current_user_email: str, admin_count: int, root_path: str) -> str: 

6259 """Render a single user card HTML snippet matching the users list template. 

6260 

6261 Args: 

6262 user_obj: User record to render. 

6263 current_user_email: Email of the current user for "You" badge logic. 

6264 admin_count: Count of active admins to protect the last admin. 

6265 root_path: Application root path for HTMX endpoints. 

6266 

6267 Returns: 

6268 HTML snippet for the user card. 

6269 """ 

6270 encoded_email = urllib.parse.quote(user_obj.email, safe="") 

6271 display_name = html.escape(user_obj.full_name or "N/A") 

6272 safe_email = html.escape(user_obj.email) 

6273 auth_provider = html.escape(user_obj.auth_provider or "unknown") 

6274 created_at = user_obj.created_at.strftime("%Y-%m-%d %H:%M") if user_obj.created_at else "Unknown" 

6275 

6276 is_current_user = user_obj.email == current_user_email 

6277 is_last_admin = bool(user_obj.is_admin and user_obj.is_active and admin_count == 1) 

6278 locked_until = getattr(user_obj, "locked_until", None) 

6279 is_locked = bool(locked_until and locked_until > utc_now()) 

6280 failed_attempts = int(getattr(user_obj, "failed_login_attempts", 0) or 0) 

6281 lock_until_text = locked_until.strftime("%Y-%m-%d %H:%M") if locked_until else "N/A" 

6282 

6283 badges = [] 

6284 if user_obj.is_admin: 

6285 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-purple-100 text-purple-800 rounded-full ' + 'dark:bg-purple-900 dark:text-purple-200">Admin</span>') 

6286 if user_obj.is_active: 

6287 badges.append('<span class="px-2 py-1 text-xs font-semibold text-green-600 bg-gray-100 dark:bg-gray-700 rounded-full">Active</span>') 

6288 else: 

6289 badges.append('<span class="px-2 py-1 text-xs font-semibold text-red-600 bg-gray-100 dark:bg-gray-700 rounded-full">Inactive</span>') 

6290 if is_current_user: 

6291 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-blue-100 text-blue-800 rounded-full ' + 'dark:bg-blue-900 dark:text-blue-200">You</span>') 

6292 if is_last_admin: 

6293 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-yellow-100 text-yellow-800 rounded-full ' + 'dark:bg-yellow-900 dark:text-yellow-200">Last Admin</span>') 

6294 if user_obj.password_change_required: 

6295 badges.append( 

6296 '<span class="px-2 py-1 text-xs font-semibold bg-orange-100 text-orange-800 rounded-full ' 

6297 'dark:bg-orange-900 dark:text-orange-200"><i class="fas fa-key mr-1"></i>Password Change Required</span>' 

6298 ) 

6299 if is_locked: 

6300 badges.append('<span class="px-2 py-1 text-xs font-semibold bg-red-100 text-red-800 rounded-full ' + 'dark:bg-red-900 dark:text-red-200"><i class="fas fa-lock mr-1"></i>Locked</span>') 

6301 

6302 actions = [ 

6303 f'<button class="px-3 py-1 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 ' 

6304 f"dark:hover:text-blue-300 border border-blue-300 dark:border-blue-600 hover:border-blue-500 " 

6305 f"dark:hover:border-blue-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 " 

6306 f'focus:ring-blue-500" hx-get="{root_path}/admin/users/{encoded_email}/edit" ' 

6307 f'hx-target="#user-edit-modal-content">Edit</button>' 

6308 ] 

6309 

6310 if not is_current_user and not is_last_admin: 

6311 if is_locked: 

6312 actions.append( 

6313 f'<button class="px-3 py-1 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 ' 

6314 f"dark:hover:text-indigo-300 border border-indigo-300 dark:border-indigo-600 hover:border-indigo-500 " 

6315 f"dark:hover:border-indigo-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 " 

6316 f'focus:ring-indigo-500" hx-post="{root_path}/admin/users/{encoded_email}/unlock" ' 

6317 f'hx-confirm="Unlock this user account?" hx-target="closest .user-card" hx-swap="outerHTML">Unlock</button>' 

6318 ) 

6319 

6320 if user_obj.is_active: 

6321 actions.append( 

6322 f'<button class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 hover:text-orange-800 ' 

6323 f"dark:hover:text-orange-300 border border-orange-300 dark:border-orange-600 hover:border-orange-500 " 

6324 f"dark:hover:border-orange-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 " 

6325 f'focus:ring-orange-500" hx-post="{root_path}/admin/users/{encoded_email}/deactivate" ' 

6326 f'hx-confirm="Deactivate this user?" hx-target="closest .user-card" hx-swap="outerHTML">Deactivate</button>' 

6327 ) 

6328 else: 

6329 actions.append( 

6330 f'<button class="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:text-green-800 ' 

6331 f"dark:hover:text-green-300 border border-green-300 dark:border-green-600 hover:border-green-500 " 

6332 f"dark:hover:border-green-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 " 

6333 f'focus:ring-green-500" hx-post="{root_path}/admin/users/{encoded_email}/activate" ' 

6334 f'hx-confirm="Activate this user?" hx-target="closest .user-card" hx-swap="outerHTML">Activate</button>' 

6335 ) 

6336 

6337 if user_obj.password_change_required: 

6338 actions.append( 

6339 '<span class="px-3 py-1 text-sm font-medium text-orange-600 dark:text-orange-400 bg-orange-50 ' 

6340 'dark:bg-orange-900/20 border border-orange-300 dark:border-orange-600 rounded-md">Password Change Required</span>' 

6341 ) 

6342 else: 

6343 actions.append( 

6344 f'<button class="px-3 py-1 text-sm font-medium text-yellow-600 dark:text-yellow-400 hover:text-yellow-800 ' 

6345 f"dark:hover:text-yellow-300 border border-yellow-300 dark:border-yellow-600 hover:border-yellow-500 " 

6346 f"dark:hover:border-yellow-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 " 

6347 f'focus:ring-yellow-500" hx-post="{root_path}/admin/users/{encoded_email}/force-password-change" ' 

6348 f'hx-confirm="Force this user to change their password on next login?" hx-target="closest .user-card" ' 

6349 f'hx-swap="outerHTML">Force Password Change</button>' 

6350 ) 

6351 

6352 actions.append( 

6353 f'<button class="px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:text-red-800 ' 

6354 f"dark:hover:text-red-300 border border-red-300 dark:border-red-600 hover:border-red-500 " 

6355 f"dark:hover:border-red-400 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 " 

6356 f'focus:ring-red-500" hx-delete="{root_path}/admin/users/{encoded_email}" ' 

6357 f'hx-confirm="Are you sure you want to delete this user? This action cannot be undone." ' 

6358 f'hx-target="closest .user-card" hx-swap="outerHTML">Delete</button>' 

6359 ) 

6360 

6361 return f""" 

6362 <div class="user-card border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800"> 

6363 <div class="flex justify-between items-start"> 

6364 <div class="flex-1"> 

6365 <div class="flex items-center gap-2 mb-2"> 

6366 <h3 class="text-lg font-semibold text-gray-900 dark:text-white">{display_name}</h3> 

6367 {" ".join(badges)} 

6368 </div> 

6369 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">📧 {safe_email}</p> 

6370 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">🔐 Provider: {auth_provider}</p> 

6371 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">⚠️ Failed attempts: {failed_attempts}</p> 

6372 <p class="text-sm text-gray-600 dark:text-gray-400 mb-2">🔒 Locked until: {lock_until_text}</p> 

6373 <p class="text-sm text-gray-600 dark:text-gray-400">📅 Created: {created_at}</p> 

6374 </div> 

6375 <div class="flex gap-2 ml-4"> 

6376 {" ".join(actions)} 

6377 </div> 

6378 </div> 

6379 </div> 

6380 """ 

6381 

6382 

6383@admin_router.get("/users") 

6384@require_permission("admin.user_management", allow_admin_bypass=False) 

6385async def admin_list_users( 

6386 request: Request, 

6387 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

6388 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

6389 db: Session = Depends(get_db), 

6390 user=Depends(get_current_user_with_permissions), 

6391) -> Response: 

6392 """ 

6393 List users for the admin UI with pagination support. 

6394 

6395 This endpoint retrieves a paginated list of users from the database. 

6396 Uses offset-based (page/per_page) pagination. 

6397 Supports JSON response for dropdown population when format=json query parameter is provided. 

6398 

6399 Args: 

6400 request: FastAPI request object 

6401 page: Page number (1-indexed). Default: 1. 

6402 per_page: Items per page. Default: 50. 

6403 db: Database session dependency 

6404 user: Authenticated user dependency 

6405 

6406 Returns: 

6407 Dict with 'data', 'pagination', and 'links' keys containing paginated users, 

6408 or JSON response for dropdown population. 

6409 """ 

6410 if not settings.email_auth_enabled: 

6411 return HTMLResponse( 

6412 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. User management requires email auth.</p></div>', 

6413 status_code=200, 

6414 ) 

6415 

6416 LOGGER.debug(f"User {get_user_email(user)} requested user list (page={page}, per_page={per_page})") 

6417 

6418 auth_service = EmailAuthService(db) 

6419 

6420 # Check if JSON response is requested (for dropdown population) 

6421 accept_header = request.headers.get("accept", "") 

6422 is_json_request = "application/json" in accept_header or request.query_params.get("format") == "json" 

6423 

6424 if is_json_request: 

6425 # Return JSON for dropdown population - always return first page with 100 users 

6426 paginated_result = await auth_service.list_users(page=1, per_page=100) 

6427 users_data = [{"email": user_obj.email, "full_name": user_obj.full_name, "is_active": user_obj.is_active, "is_admin": user_obj.is_admin} for user_obj in paginated_result.data] 

6428 return ORJSONResponse(content={"users": users_data}) 

6429 

6430 # List users with page-based pagination 

6431 paginated_result = await auth_service.list_users(page=page, per_page=per_page) 

6432 

6433 # End the read-only transaction early to avoid idle-in-transaction under load 

6434 db.commit() 

6435 

6436 # Return standardized paginated response (for legacy compatibility) 

6437 return ORJSONResponse( 

6438 content={ 

6439 "data": [{"email": u.email, "full_name": u.full_name, "is_active": u.is_active, "is_admin": u.is_admin} for u in paginated_result.data], 

6440 "pagination": paginated_result.pagination.model_dump() if paginated_result.pagination else None, 

6441 "links": paginated_result.links.model_dump() if paginated_result.links else None, 

6442 } 

6443 ) 

6444 

6445 

6446@admin_router.get("/users/partial", response_class=HTMLResponse) 

6447@require_permission("admin.user_management", allow_admin_bypass=False) 

6448async def admin_users_partial_html( 

6449 request: Request, 

6450 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

6451 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

6452 render: Optional[str] = Query(None, description="Render mode: 'selector' for user selector items, 'controls' for pagination controls"), 

6453 team_id: Optional[str] = Depends(_validated_team_id_param), 

6454 db: Session = Depends(get_db), 

6455 user=Depends(get_current_user_with_permissions), 

6456) -> Response: 

6457 """ 

6458 Return paginated users as HTML partial for HTMX requests. 

6459 

6460 This endpoint returns rendered HTML for the users list with pagination controls, 

6461 designed for HTMX-based dynamic updates. 

6462 

6463 Args: 

6464 request: FastAPI request object 

6465 page: Page number (1-indexed). Default: 1. 

6466 per_page: Items per page. Default: 50. 

6467 render: Render mode - 'selector' returns user selector items, 'controls' returns pagination controls. 

6468 team_id: Optional team ID to pre-select members in selector mode 

6469 db: Database session 

6470 user: Current authenticated user context 

6471 

6472 Returns: 

6473 Response: HTML response with users list and pagination controls 

6474 """ 

6475 try: 

6476 if not settings.email_auth_enabled: 

6477 return HTMLResponse( 

6478 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled. User management requires email auth.</p></div>', 

6479 status_code=200, 

6480 ) 

6481 

6482 auth_service = EmailAuthService(db) 

6483 

6484 # List users with page-based pagination 

6485 paginated_result = await auth_service.list_users(page=page, per_page=per_page) 

6486 users_db = paginated_result.data 

6487 pagination = typing_cast(PaginationMeta, paginated_result.pagination) 

6488 

6489 # Get current user email 

6490 current_user_email = get_user_email(user) 

6491 

6492 # Check how many active admins we have 

6493 admin_count = await auth_service.count_active_admin_users() 

6494 

6495 # Prepare user data for template with additional flags 

6496 users_data = [] 

6497 for user_obj in users_db: 

6498 is_current_user = user_obj.email == current_user_email 

6499 is_last_admin = user_obj.is_admin and user_obj.is_active and admin_count == 1 

6500 

6501 users_data.append( 

6502 { 

6503 "email": user_obj.email, 

6504 "full_name": user_obj.full_name, 

6505 "is_active": user_obj.is_active, 

6506 "is_admin": user_obj.is_admin, 

6507 "auth_provider": user_obj.auth_provider, 

6508 "created_at": user_obj.created_at, 

6509 "password_change_required": user_obj.password_change_required, 

6510 "failed_login_attempts": int(getattr(user_obj, "failed_login_attempts", 0) or 0), 

6511 "locked_until": getattr(user_obj, "locked_until", None), 

6512 "is_locked": bool(getattr(user_obj, "locked_until", None) and getattr(user_obj, "locked_until", None) > utc_now()), 

6513 "is_current_user": is_current_user, 

6514 "is_last_admin": is_last_admin, 

6515 } 

6516 ) 

6517 

6518 # Get team members if team_id is provided (for pre-selection in team member addition) 

6519 team_member_emails = set() 

6520 team_member_data = {} 

6521 current_user_is_team_owner = False 

6522 

6523 if team_id and render == "selector": 

6524 team_service = TeamManagementService(db) 

6525 try: 

6526 team_members = await team_service.get_team_members(team_id) 

6527 team_member_emails = {team_user.email for team_user, membership in team_members} 

6528 

6529 # Build enhanced member data from the same query result (no extra DB calls!) 

6530 # Count owners in-memory 

6531 owner_count = sum(1 for _, membership in team_members if membership.role == "owner") 

6532 

6533 # Build member data dict and find current user's role 

6534 for team_user, membership in team_members: 

6535 email = team_user.email 

6536 is_last_owner = membership.role == "owner" and owner_count == 1 

6537 team_member_data[email] = type("MemberData", (), {"role": membership.role, "joined_at": membership.joined_at, "is_last_owner": is_last_owner})() 

6538 

6539 # Check if current user is owner (in-memory check) 

6540 if email == current_user_email and membership.role == "owner": 

6541 current_user_is_team_owner = True 

6542 

6543 except Exception as e: 

6544 LOGGER.warning(f"Could not fetch team members for team {team_id}: {e}") 

6545 

6546 # End the read-only transaction early to avoid idle-in-transaction under load 

6547 db.commit() 

6548 

6549 if render == "selector": 

6550 return request.app.state.templates.TemplateResponse( 

6551 request, 

6552 "team_members_selector.html", 

6553 { 

6554 "request": request, 

6555 "data": users_data, 

6556 "pagination": pagination.model_dump(), 

6557 "root_path": request.scope.get("root_path", ""), 

6558 "team_member_emails": team_member_emails, 

6559 "team_member_data": team_member_data, 

6560 "current_user_email": current_user_email, 

6561 "current_user_is_team_owner": current_user_is_team_owner, 

6562 "team_id": team_id, 

6563 }, 

6564 ) 

6565 

6566 if render == "controls": 

6567 base_url = f"{request.scope.get('root_path', '')}/admin/users/partial" 

6568 return request.app.state.templates.TemplateResponse( 

6569 request, 

6570 "pagination_controls.html", 

6571 { 

6572 "request": request, 

6573 "pagination": pagination.model_dump(), 

6574 "base_url": base_url, 

6575 "hx_target": "#users-list-container", 

6576 "hx_indicator": "#users-loading", 

6577 "hx_swap": "outerHTML", 

6578 "query_params": {}, 

6579 "root_path": request.scope.get("root_path", ""), 

6580 }, 

6581 ) 

6582 

6583 # Render template with paginated data 

6584 return request.app.state.templates.TemplateResponse( 

6585 request, 

6586 "users_partial.html", 

6587 { 

6588 "request": request, 

6589 "data": users_data, 

6590 "pagination": pagination.model_dump(), 

6591 "root_path": request.scope.get("root_path", ""), 

6592 "current_user_email": current_user_email, 

6593 }, 

6594 ) 

6595 

6596 except Exception as e: 

6597 LOGGER.error(f"Error loading users partial for admin {user}: {e}") 

6598 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading users: {html.escape(str(e))}</p></div>', status_code=200) 

6599 

6600 

6601@admin_router.get("/teams/{team_id}/members/partial", response_class=HTMLResponse) 

6602@require_permission("teams.manage_members", allow_admin_bypass=False) 

6603async def admin_team_members_partial_html( 

6604 team_id: str, 

6605 request: Request, 

6606 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

6607 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

6608 db: Session = Depends(get_db), 

6609 user=Depends(get_current_user_with_permissions), 

6610) -> Response: 

6611 """Return paginated team members for two-section layout (top section). 

6612 

6613 Args: 

6614 team_id: Team identifier. 

6615 request: FastAPI request object. 

6616 page: Page number (1-indexed). Default: 1. 

6617 per_page: Items per page. Default: 50. 

6618 db: Database session. 

6619 user: Current authenticated user context. 

6620 

6621 Returns: 

6622 Response: HTML response with team members and pagination data. 

6623 """ 

6624 try: 

6625 if not settings.email_auth_enabled: 

6626 return HTMLResponse( 

6627 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled.</p></div>', 

6628 status_code=200, 

6629 ) 

6630 

6631 team_service = TeamManagementService(db) 

6632 current_user_email = get_user_email(user) 

6633 

6634 try: 

6635 team_id = _normalize_team_id(team_id) 

6636 except ValueError: 

6637 return HTMLResponse(content='<div class="text-red-500">Invalid team ID</div>', status_code=400) 

6638 

6639 team = await team_service.get_team_by_id(team_id) 

6640 if not team: 

6641 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

6642 

6643 current_user_role = await team_service.get_user_role_in_team(current_user_email, team_id) 

6644 if current_user_role != "owner": 

6645 return HTMLResponse(content='<div class="text-red-500">Only team owners can manage members</div>', status_code=403) 

6646 

6647 # Get paginated team members 

6648 paginated_result = await team_service.get_team_members(team_id, page=page, per_page=per_page) 

6649 members = paginated_result["data"] 

6650 pagination = paginated_result["pagination"] 

6651 

6652 # Count owners for is_last_owner check - must count ALL owners, not just current page 

6653 owner_count = team_service.count_team_owners(team_id) 

6654 

6655 # End the read-only transaction early 

6656 db.commit() 

6657 

6658 root_path = request.scope.get("root_path", "") 

6659 next_page_url = f"{root_path}/admin/teams/{team_id}/members/partial?page={pagination.page + 1}&per_page={pagination.per_page}" 

6660 response = request.app.state.templates.TemplateResponse( 

6661 request, 

6662 "team_users_selector.html", 

6663 { 

6664 "request": request, 

6665 "data": members, # List of (user, membership) tuples 

6666 "pagination": pagination.model_dump(), 

6667 "root_path": root_path, 

6668 "current_user_email": current_user_email, 

6669 "current_user_is_team_owner": True, # Already verified above 

6670 "owner_count": owner_count, 

6671 "team_id": team_id, 

6672 "is_members_list": True, 

6673 "scroll_trigger_id": "members-scroll-trigger", 

6674 "next_page_url": next_page_url, 

6675 }, 

6676 ) 

6677 # Prevent nginx caching for real-time member list updates 

6678 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 

6679 response.headers["Pragma"] = "no-cache" 

6680 response.headers["Expires"] = "0" 

6681 return response 

6682 

6683 except Exception as e: 

6684 LOGGER.error(f"Error loading team members partial for team {team_id}: {e}") 

6685 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading members: {html.escape(str(e))}</p></div>', status_code=200) 

6686 

6687 

6688@admin_router.get("/teams/{team_id}/non-members/partial", response_class=HTMLResponse) 

6689@require_permission("teams.manage_members", allow_admin_bypass=False) 

6690async def admin_team_non_members_partial_html( 

6691 team_id: str, 

6692 request: Request, 

6693 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

6694 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

6695 db: Session = Depends(get_db), 

6696 user=Depends(get_current_user_with_permissions), 

6697) -> Response: 

6698 """Return paginated non-members for two-section layout (bottom section). 

6699 

6700 Args: 

6701 team_id: Team identifier. 

6702 request: FastAPI request object. 

6703 page: Page number (1-indexed). Default: 1. 

6704 per_page: Items per page. Default: 50. 

6705 db: Database session. 

6706 user: Current authenticated user context. 

6707 

6708 Returns: 

6709 Response: HTML response with non-members and pagination data. 

6710 """ 

6711 try: 

6712 if not settings.email_auth_enabled: 

6713 return HTMLResponse( 

6714 content='<div class="text-center py-8"><p class="text-gray-500">Email authentication is disabled.</p></div>', 

6715 status_code=200, 

6716 ) 

6717 

6718 auth_service = EmailAuthService(db) 

6719 team_service = TeamManagementService(db) 

6720 current_user_email = get_user_email(user) 

6721 

6722 try: 

6723 team_id = _normalize_team_id(team_id) 

6724 except ValueError: 

6725 return HTMLResponse(content='<div class="text-red-500">Invalid team ID</div>', status_code=400) 

6726 

6727 team = await team_service.get_team_by_id(team_id) 

6728 if not team: 

6729 return HTMLResponse(content='<div class="text-red-500">Team not found</div>', status_code=404) 

6730 

6731 current_user_role = await team_service.get_user_role_in_team(current_user_email, team_id) 

6732 if current_user_role != "owner": 

6733 return HTMLResponse(content='<div class="text-red-500">Only team owners can manage members</div>', status_code=403) 

6734 

6735 # Get paginated non-members 

6736 paginated_result = await auth_service.list_users_not_in_team(team_id, page=page, per_page=per_page) 

6737 users = paginated_result.data 

6738 pagination = typing_cast(PaginationMeta, paginated_result.pagination) 

6739 

6740 # End the read-only transaction early 

6741 db.commit() 

6742 

6743 root_path = request.scope.get("root_path", "") 

6744 next_page_url = f"{root_path}/admin/teams/{team_id}/non-members/partial?page={pagination.page + 1}&per_page={pagination.per_page}" 

6745 response = request.app.state.templates.TemplateResponse( 

6746 request, 

6747 "team_users_selector.html", 

6748 { 

6749 "request": request, 

6750 "data": users, # List of user objects 

6751 "pagination": pagination.model_dump(), 

6752 "root_path": root_path, 

6753 "current_user_email": current_user_email, 

6754 "current_user_is_team_owner": True, # Already verified above 

6755 "owner_count": 0, # Not relevant for non-members 

6756 "team_id": team_id, 

6757 "is_members_list": False, 

6758 "scroll_trigger_id": "non-members-scroll-trigger", 

6759 "next_page_url": next_page_url, 

6760 }, 

6761 ) 

6762 # Prevent nginx caching for real-time non-member list updates 

6763 response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 

6764 response.headers["Pragma"] = "no-cache" 

6765 response.headers["Expires"] = "0" 

6766 return response 

6767 

6768 except Exception as e: 

6769 LOGGER.error(f"Error loading team non-members partial for team {team_id}: {e}") 

6770 return HTMLResponse(content=f'<div class="text-center py-8"><p class="text-red-500">Error loading non-members: {html.escape(str(e))}</p></div>', status_code=200) 

6771 

6772 

6773@admin_router.get("/users/search", response_class=JSONResponse) 

6774@require_any_permission(["admin.user_management", "teams.manage_members"], allow_admin_bypass=False) 

6775async def admin_search_users( 

6776 q: str = Query("", description="Search query"), 

6777 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"), 

6778 db: Session = Depends(get_db), 

6779 user=Depends(get_current_user_with_permissions), 

6780): 

6781 """ 

6782 Search users by email or full name. 

6783 

6784 This endpoint searches users for use in search functionality like team member selection. 

6785 

6786 Args: 

6787 q (str): Search query string to match against email or full name 

6788 limit (int): Maximum number of results to return 

6789 db (Session): Database session dependency 

6790 user: Current user making the request 

6791 

6792 Returns: 

6793 JSONResponse: Dictionary containing list of matching users and count 

6794 """ 

6795 search_query = _normalize_search_query(q) 

6796 if not settings.email_auth_enabled: 

6797 return _build_search_response(entity_key="users", entity_type="users", items=[], query=search_query, tags="", tag_groups=[]) 

6798 

6799 user_email = get_user_email(user) 

6800 

6801 if not search_query: 

6802 return _build_search_response(entity_key="users", entity_type="users", items=[], query=search_query, tags="", tag_groups=[]) 

6803 

6804 LOGGER.debug(f"User {user_email} searching users with query: {search_query}") 

6805 

6806 auth_service = EmailAuthService(db) 

6807 

6808 # Use list_users with search parameter 

6809 users_result = await auth_service.list_users(search=search_query, limit=limit) 

6810 users_list = users_result.data 

6811 

6812 # Format results for JSON response 

6813 results = [ 

6814 { 

6815 "id": user_obj.email, 

6816 "name": user_obj.full_name or user_obj.email, 

6817 "email": user_obj.email, 

6818 "full_name": user_obj.full_name or "", 

6819 "is_active": user_obj.is_active, 

6820 "is_admin": user_obj.is_admin, 

6821 } 

6822 for user_obj in users_list 

6823 ] 

6824 

6825 return _build_search_response(entity_key="users", entity_type="users", items=results, query=search_query, tags="", tag_groups=[]) 

6826 

6827 

6828@admin_router.post("/users") 

6829@require_permission("admin.user_management", allow_admin_bypass=False) 

6830async def admin_create_user( 

6831 request: Request, 

6832 db: Session = Depends(get_db), 

6833 user=Depends(get_current_user_with_permissions), 

6834) -> HTMLResponse: 

6835 """Create a new user via admin UI. 

6836 

6837 Args: 

6838 request: FastAPI request object 

6839 db: Database session 

6840 user: Current authenticated user context 

6841 

6842 Returns: 

6843 HTMLResponse: Success message or error response 

6844 """ 

6845 try: 

6846 form = await request.form() 

6847 

6848 # Validate password strength 

6849 password = str(form.get("password", "")) 

6850 if password: 

6851 is_valid, error_msg = validate_password_strength(password) 

6852 if not is_valid: 

6853 return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400) 

6854 

6855 # First-Party 

6856 

6857 auth_service = EmailAuthService(db) 

6858 

6859 # Create new user 

6860 new_user = await auth_service.create_user( 

6861 email=str(form.get("email", "")), 

6862 password=password, 

6863 full_name=str(form.get("full_name", "")), 

6864 is_admin=form.get("is_admin") == "on", 

6865 auth_provider="local", 

6866 granted_by=get_user_email(user), # Pass current admin user for audit trail 

6867 ) 

6868 

6869 # If the user was created with the default password, optionally force password change 

6870 if ( 

6871 settings.password_change_enforcement_enabled and getattr(settings, "require_password_change_for_default_password", True) and password == settings.default_user_password.get_secret_value() 

6872 ): # nosec B105 

6873 new_user.password_change_required = True 

6874 db.commit() 

6875 

6876 LOGGER.info(f"Admin {user} created user: {new_user.email}") 

6877 

6878 # Return HX-Trigger header to refresh the users list 

6879 # This will trigger a reload of the users-list-container 

6880 response = HTMLResponse(content='<div class="text-green-500">User created successfully!</div>', status_code=201) 

6881 response.headers["HX-Trigger"] = "userCreated" 

6882 return response 

6883 

6884 except Exception as e: 

6885 LOGGER.error(f"Error creating user by admin {user}: {e}") 

6886 return HTMLResponse(content=f'<div class="text-red-500">Error creating user: {html.escape(str(e))}</div>', status_code=400) 

6887 

6888 

6889@admin_router.get("/users/{user_email}/edit") 

6890@require_permission("admin.user_management", allow_admin_bypass=False) 

6891async def admin_get_user_edit( 

6892 user_email: str, 

6893 _request: Request, 

6894 db: Session = Depends(get_db), 

6895 _user=Depends(get_current_user_with_permissions), 

6896) -> HTMLResponse: 

6897 """Get user edit form via admin UI. 

6898 

6899 Args: 

6900 user_email: Email of user to edit 

6901 db: Database session 

6902 

6903 Returns: 

6904 HTMLResponse: User edit form HTML 

6905 """ 

6906 if not settings.email_auth_enabled: 

6907 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

6908 

6909 try: 

6910 # Get root path for URL construction 

6911 root_path = _request.scope.get("root_path", "") if _request else "" 

6912 

6913 # First-Party 

6914 

6915 auth_service = EmailAuthService(db) 

6916 

6917 # URL decode the email 

6918 

6919 decoded_email = urllib.parse.unquote(user_email) 

6920 

6921 user_obj = await auth_service.get_user_by_email(decoded_email) 

6922 if not user_obj: 

6923 return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404) 

6924 

6925 # Get current user's email to check if editing self 

6926 current_user_email = get_user_email(_user) 

6927 is_editing_self = current_user_email.lower() == decoded_email.lower() 

6928 

6929 # Build Password Requirements HTML separately to avoid backslash issues inside f-strings 

6930 if settings.password_require_uppercase or settings.password_require_lowercase or settings.password_require_numbers or settings.password_require_special: 

6931 pr_lines = [] 

6932 pr_lines.append( 

6933 f""" <!-- Password Requirements --> 

6934 <div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4"> 

6935 <div class="flex items-start"> 

6936 <svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor"> 

6937 <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/> 

6938 </svg> 

6939 <div class="ml-3 flex-1"> 

6940 <h3 class="text-sm font-semibold text-blue-900 dark:text-blue-200">Password Requirements</h3> 

6941 <div class="mt-2 text-sm text-blue-800 dark:text-blue-300 space-y-1"> 

6942 <div class="flex items-center" id="edit-req-length"> 

6943 <span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span> 

6944 <span>At least {settings.password_min_length} characters long</span> 

6945 </div> 

6946 """ 

6947 ) 

6948 if settings.password_require_uppercase: 

6949 pr_lines.append( 

6950 """ 

6951 <div class="flex items-center" id="edit-req-uppercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains uppercase letters (A-Z)</span></div> 

6952 """ 

6953 ) 

6954 if settings.password_require_lowercase: 

6955 pr_lines.append( 

6956 """ 

6957 <div class="flex items-center" id="edit-req-lowercase"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains lowercase letters (a-z)</span></div> 

6958 """ 

6959 ) 

6960 if settings.password_require_numbers: 

6961 pr_lines.append( 

6962 """ 

6963 <div class="flex items-center" id="edit-req-numbers"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains numbers (0-9)</span></div> 

6964 """ 

6965 ) 

6966 if settings.password_require_special: 

6967 pr_lines.append( 

6968 """ 

6969 <div class="flex items-center" id="edit-req-special"><span class="inline-flex items-center justify-center w-4 h-4 bg-gray-400 text-white rounded-full text-xs mr-2">✗</span><span>Contains special characters (!@#$%^&amp;*(),.?&quot;:{{}}|&lt;&gt;)</span></div> 

6970 """ 

6971 ) 

6972 pr_lines.append( 

6973 """ 

6974 </div> 

6975 </div> 

6976 </div> 

6977 </div> 

6978 """ 

6979 ) 

6980 password_requirements_html = "".join(pr_lines) 

6981 else: 

6982 # Intentionally an empty string for HTML insertion when no requirements apply. 

6983 # This is not a password value; suppress Bandit false positive B105. 

6984 password_requirements_html = "" # nosec B105 

6985 

6986 # Create edit form HTML 

6987 edit_form = f""" 

6988 <div class="space-y-4"> 

6989 <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit User</h3> 

6990 <div id="edit-user-error"></div> 

6991 <form hx-post="{root_path}/admin/users/{user_email}/update" hx-target="#edit-user-error" hx-swap="innerHTML" class="space-y-4"> 

6992 <div> 

6993 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label> 

6994 <input type="email" name="email" value="{user_obj.email}" readonly 

6995 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white"> 

6996 </div> 

6997 <div> 

6998 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label> 

6999 <input type="text" name="full_name" value="{user_obj.full_name or ""}" required 

7000 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white"> 

7001 </div> 

7002 {"" if is_editing_self else f'''<div> 

7003 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 

7004 <input type="checkbox" name="is_admin" {"checked" if user_obj.is_admin else ""} 

7005 class="mr-2"> Administrator 

7006 </label> 

7007 </div>'''} 

7008 <div> 

7009 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">New Password (leave empty to keep current)</label> 

7010 <input type="password" name="password" id="password-field" 

7011 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white" 

7012 oninput="validatePasswordRequirements(); validatePasswordMatch();"> 

7013 </div> 

7014 <div> 

7015 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm New Password</label> 

7016 <input type="password" name="confirm_password" id="confirm-password-field" 

7017 class="mt-1 px-1.5 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 text-gray-900 dark:text-white" 

7018 oninput="validatePasswordMatch()"> 

7019 <div id="password-match-message" class="mt-1 text-sm text-red-600 hidden">Passwords do not match</div> 

7020 </div> 

7021 {password_requirements_html} 

7022 <div 

7023 id="edit-password-policy-data" 

7024 class="hidden" 

7025 data-min-length="{settings.password_min_length}" 

7026 data-require-uppercase="{"true" if settings.password_require_uppercase else "false"}" 

7027 data-require-lowercase="{"true" if settings.password_require_lowercase else "false"}" 

7028 data-require-numbers="{"true" if settings.password_require_numbers else "false"}" 

7029 data-require-special="{"true" if settings.password_require_special else "false"}" 

7030 ></div> 

7031 <div class="flex justify-end space-x-3"> 

7032 <button type="button" onclick="hideUserEditModal()" 

7033 class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"> 

7034 Cancel 

7035 </button> 

7036 <button type="submit" 

7037 class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> 

7038 Update User 

7039 </button> 

7040 </div> 

7041 </form> 

7042 </div> 

7043 """ 

7044 return HTMLResponse(content=edit_form) 

7045 

7046 except Exception as e: 

7047 LOGGER.error(f"Error getting user edit form for {user_email}: {e}") 

7048 return HTMLResponse(content=f'<div class="text-red-500">Error loading user: {html.escape(str(e))}</div>', status_code=500) 

7049 

7050 

7051@admin_router.post("/users/{user_email}/update") 

7052@require_permission("admin.user_management", allow_admin_bypass=False) 

7053async def admin_update_user( 

7054 user_email: str, 

7055 request: Request, 

7056 db: Session = Depends(get_db), 

7057 _user=Depends(get_current_user_with_permissions), 

7058) -> HTMLResponse: 

7059 """Update user via admin UI. 

7060 

7061 Args: 

7062 user_email: Email of user to update 

7063 request: FastAPI request object 

7064 db: Database session 

7065 

7066 Returns: 

7067 HTMLResponse: Success message or error response 

7068 """ 

7069 if not settings.email_auth_enabled: 

7070 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

7071 

7072 try: 

7073 # First-Party 

7074 

7075 auth_service = EmailAuthService(db) 

7076 

7077 # URL decode the email 

7078 

7079 decoded_email = urllib.parse.unquote(user_email) 

7080 

7081 form = await request.form() 

7082 full_name = form.get("full_name") 

7083 is_admin = form.get("is_admin") == "on" 

7084 password = form.get("password") 

7085 confirm_password = form.get("confirm_password") 

7086 

7087 # Validate password confirmation if password is being changed 

7088 if password and password != confirm_password: 

7089 return HTMLResponse(content='<div class="text-red-500">Passwords do not match</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"}) 

7090 

7091 # Get current user's email to prevent self-demotion 

7092 current_user_email = get_user_email(_user) 

7093 

7094 # Check if trying to remove admin privileges from last admin 

7095 user_obj = await auth_service.get_user_by_email(decoded_email) 

7096 

7097 # When editing self, preserve current admin status (checkbox is hidden in UI) 

7098 if user_obj and current_user_email.lower() == decoded_email.lower(): 

7099 is_admin = user_obj.is_admin 

7100 

7101 if user_obj and user_obj.is_admin and not is_admin: 

7102 # This user is currently an admin and we're trying to remove admin privileges 

7103 if await auth_service.is_last_active_admin(decoded_email): 

7104 return HTMLResponse( 

7105 content='<div class="text-red-500">Cannot remove administrator privileges from the last remaining admin user</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"} 

7106 ) 

7107 

7108 # Update user 

7109 fn_val = form.get("full_name") 

7110 pw_val = form.get("password") 

7111 full_name = fn_val if isinstance(fn_val, str) else None 

7112 password = pw_val.strip() if isinstance(pw_val, str) and pw_val.strip() else None 

7113 

7114 # Validate password if provided 

7115 if password: 

7116 is_valid, error_msg = validate_password_strength(password) 

7117 if not is_valid: 

7118 return HTMLResponse(content=f'<div class="text-red-500">Password validation failed: {error_msg}</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"}) 

7119 

7120 await auth_service.update_user(email=decoded_email, full_name=full_name, is_admin=is_admin, password=password, admin_origin_source="ui") 

7121 

7122 # Return success message with auto-close and refresh 

7123 success_html = """ 

7124 <div class="text-green-500 text-center p-4"> 

7125 <p>User updated successfully</p> 

7126 </div> 

7127 """ 

7128 response = HTMLResponse(content=success_html) 

7129 response.headers["HX-Trigger"] = orjson.dumps({"adminUserAction": {"closeUserEditModal": True, "refreshUsersList": True, "delayMs": 1500}}).decode() 

7130 return response 

7131 

7132 except Exception as e: 

7133 LOGGER.error(f"Error updating user {user_email}: {e}") 

7134 return HTMLResponse(content=f'<div class="text-red-500">Error updating user: {html.escape(str(e))}</div>', status_code=400, headers={"HX-Retarget": "#edit-user-error"}) 

7135 

7136 

7137@admin_router.post("/users/{user_email}/activate") 

7138@require_permission("admin.user_management", allow_admin_bypass=False) 

7139async def admin_activate_user( 

7140 user_email: str, 

7141 _request: Request, 

7142 db: Session = Depends(get_db), 

7143 user=Depends(get_current_user_with_permissions), 

7144) -> HTMLResponse: 

7145 """Activate user via admin UI. 

7146 

7147 Args: 

7148 user_email: Email of user to activate 

7149 db: Database session 

7150 user: Current authenticated user context 

7151 

7152 Returns: 

7153 HTMLResponse: Success message or error response 

7154 """ 

7155 if not settings.email_auth_enabled: 

7156 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

7157 

7158 try: 

7159 # Get root path for URL construction 

7160 root_path = _request.scope.get("root_path", "") if _request else "" 

7161 

7162 # First-Party 

7163 

7164 auth_service = EmailAuthService(db) 

7165 

7166 # URL decode the email 

7167 

7168 decoded_email = urllib.parse.unquote(user_email) 

7169 

7170 # Get current user email from JWT (used for logging purposes) 

7171 current_user_email = get_user_email(user) 

7172 

7173 user_obj = await auth_service.activate_user(decoded_email) 

7174 admin_count = await auth_service.count_active_admin_users() 

7175 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path)) 

7176 

7177 except Exception as e: 

7178 LOGGER.error(f"Error activating user {user_email}: {e}") 

7179 return HTMLResponse(content=f'<div class="text-red-500">Error activating user: {html.escape(str(e))}</div>', status_code=400) 

7180 

7181 

7182@admin_router.post("/users/{user_email}/deactivate") 

7183@require_permission("admin.user_management", allow_admin_bypass=False) 

7184async def admin_deactivate_user( 

7185 user_email: str, 

7186 _request: Request, 

7187 db: Session = Depends(get_db), 

7188 user=Depends(get_current_user_with_permissions), 

7189) -> HTMLResponse: 

7190 """Deactivate user via admin UI. 

7191 

7192 Args: 

7193 user_email: Email of user to deactivate 

7194 db: Database session 

7195 user: Current authenticated user context 

7196 

7197 Returns: 

7198 HTMLResponse: Success message or error response 

7199 """ 

7200 if not settings.email_auth_enabled: 

7201 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

7202 

7203 try: 

7204 # Get root path for URL construction 

7205 root_path = _request.scope.get("root_path", "") if _request else "" 

7206 

7207 # First-Party 

7208 

7209 auth_service = EmailAuthService(db) 

7210 

7211 # URL decode the email 

7212 

7213 decoded_email = urllib.parse.unquote(user_email) 

7214 

7215 # Get current user email from JWT 

7216 current_user_email = get_user_email(user) 

7217 

7218 # Prevent self-deactivation 

7219 if decoded_email == current_user_email: 

7220 return HTMLResponse(content='<div class="text-red-500">Cannot deactivate your own account</div>', status_code=400) 

7221 

7222 # Prevent deactivating the last active admin user 

7223 if await auth_service.is_last_active_admin(decoded_email): 

7224 return HTMLResponse(content='<div class="text-red-500">Cannot deactivate the last remaining admin user</div>', status_code=400) 

7225 

7226 user_obj = await auth_service.deactivate_user(decoded_email) 

7227 admin_count = await auth_service.count_active_admin_users() 

7228 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path)) 

7229 

7230 except Exception as e: 

7231 LOGGER.error(f"Error deactivating user {user_email}: {e}") 

7232 return HTMLResponse(content=f'<div class="text-red-500">Error deactivating user: {html.escape(str(e))}</div>', status_code=400) 

7233 

7234 

7235@admin_router.delete("/users/{user_email}") 

7236@require_permission("admin.user_management", allow_admin_bypass=False) 

7237async def admin_delete_user( 

7238 user_email: str, 

7239 _request: Request, 

7240 db: Session = Depends(get_db), 

7241 user=Depends(get_current_user_with_permissions), 

7242) -> HTMLResponse: 

7243 """Delete user via admin UI. 

7244 

7245 Args: 

7246 user_email: Email address of user to delete 

7247 _request: FastAPI request object (unused) 

7248 db: Database session 

7249 user: Current authenticated user context 

7250 

7251 Returns: 

7252 HTMLResponse: Success/error message 

7253 """ 

7254 if not settings.email_auth_enabled: 

7255 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

7256 

7257 try: 

7258 # First-Party 

7259 

7260 auth_service = EmailAuthService(db) 

7261 

7262 # URL decode the email 

7263 

7264 decoded_email = urllib.parse.unquote(user_email) 

7265 

7266 # Get current user email from JWT 

7267 current_user_email = get_user_email(user) 

7268 

7269 # Prevent self-deletion 

7270 if decoded_email == current_user_email: 

7271 return HTMLResponse(content='<div class="text-red-500">Cannot delete your own account</div>', status_code=400) 

7272 

7273 # Prevent deleting the last active admin user 

7274 if await auth_service.is_last_active_admin(decoded_email): 

7275 return HTMLResponse(content='<div class="text-red-500">Cannot delete the last remaining admin user</div>', status_code=400) 

7276 

7277 await auth_service.delete_user(decoded_email) 

7278 

7279 # Return empty content to remove the user from the list 

7280 return HTMLResponse(content="", status_code=200) 

7281 

7282 except Exception as e: 

7283 LOGGER.error(f"Error deleting user {user_email}: {e}") 

7284 return HTMLResponse(content=f'<div class="text-red-500">Error deleting user: {html.escape(str(e))}</div>', status_code=400) 

7285 

7286 

7287@admin_router.post("/users/{user_email}/unlock") 

7288@require_permission("admin.user_management", allow_admin_bypass=False) 

7289async def admin_unlock_user( 

7290 user_email: str, 

7291 _request: Request, 

7292 db: Session = Depends(get_db), 

7293 user=Depends(get_current_user_with_permissions), 

7294) -> HTMLResponse: 

7295 """Unlock a user account from the admin UI. 

7296 

7297 Args: 

7298 user_email: URL-encoded email for the user to unlock. 

7299 _request: Incoming HTTP request. 

7300 db: Database session dependency. 

7301 user: Current authenticated user context. 

7302 

7303 Returns: 

7304 HTMLResponse: Updated user card HTML or error snippet. 

7305 """ 

7306 if not settings.email_auth_enabled: 

7307 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

7308 

7309 try: 

7310 root_path = _request.scope.get("root_path", "") if _request else "" 

7311 auth_service = EmailAuthService(db) 

7312 decoded_email = urllib.parse.unquote(user_email) 

7313 current_user_email = get_user_email(user) 

7314 

7315 user_obj = await auth_service.unlock_user_account(decoded_email, unlocked_by=current_user_email) 

7316 admin_count = await auth_service.count_active_admin_users() 

7317 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path)) 

7318 except ValueError as exc: 

7319 return HTMLResponse(content=f'<div class="text-red-500">{html.escape(str(exc))}</div>', status_code=404) 

7320 except Exception as exc: 

7321 LOGGER.error("Error unlocking user %s: %s", user_email, exc) 

7322 return HTMLResponse(content=f'<div class="text-red-500">Error unlocking user: {html.escape(str(exc))}</div>', status_code=400) 

7323 

7324 

7325@admin_router.post("/users/{user_email}/force-password-change") 

7326@require_permission("admin.user_management", allow_admin_bypass=False) 

7327async def admin_force_password_change( 

7328 user_email: str, 

7329 _request: Request, 

7330 db: Session = Depends(get_db), 

7331 user=Depends(get_current_user_with_permissions), 

7332) -> HTMLResponse: 

7333 """Force user to change password on next login. 

7334 

7335 Args: 

7336 user_email: Email of user to force password change 

7337 _request: FastAPI request object 

7338 db: Database session 

7339 user: Current authenticated user context 

7340 

7341 Returns: 

7342 HTMLResponse: Updated user card with success message 

7343 

7344 Examples: 

7345 >>> from unittest.mock import MagicMock, AsyncMock 

7346 >>> from fastapi import Request 

7347 >>> from fastapi.responses import HTMLResponse 

7348 >>> 

7349 >>> # Mock request 

7350 >>> mock_request = MagicMock(spec=Request) 

7351 >>> mock_request.scope = {"root_path": "/test"} 

7352 >>> 

7353 >>> # Mock database 

7354 >>> mock_db = MagicMock() 

7355 >>> 

7356 >>> # Mock user context 

7357 >>> mock_user = MagicMock() 

7358 >>> mock_user.email = "admin@example.com" 

7359 >>> 

7360 >>> import asyncio 

7361 >>> async def test_force_password_change(): 

7362 ... # Note: Full test requires email_auth_enabled and valid user 

7363 ... return True # Simplified test due to dependencies 

7364 >>> 

7365 >>> asyncio.run(test_force_password_change()) 

7366 True 

7367 """ 

7368 if not settings.email_auth_enabled: 

7369 return HTMLResponse(content='<div class="text-red-500">Email authentication is disabled</div>', status_code=403) 

7370 

7371 try: 

7372 # Get root path for URL construction 

7373 root_path = _request.scope.get("root_path", "") if _request else "" 

7374 

7375 auth_service = EmailAuthService(db) 

7376 

7377 # URL decode the email 

7378 decoded_email = urllib.parse.unquote(user_email) 

7379 

7380 # Get current user email from JWT 

7381 current_user_email = get_user_email(user) 

7382 

7383 # Get the user to update 

7384 user_obj = await auth_service.get_user_by_email(decoded_email) 

7385 if not user_obj: 

7386 return HTMLResponse(content='<div class="text-red-500">User not found</div>', status_code=404) 

7387 

7388 # Set password_change_required flag 

7389 user_obj.password_change_required = True 

7390 db.commit() 

7391 

7392 LOGGER.info(f"Admin {current_user_email} forced password change for user {decoded_email}") 

7393 

7394 admin_count = await auth_service.count_active_admin_users() 

7395 return HTMLResponse(content=_render_user_card_html(user_obj, current_user_email, admin_count, root_path)) 

7396 

7397 except Exception as e: 

7398 LOGGER.error(f"Error forcing password change for user {user_email}: {e}") 

7399 return HTMLResponse(content=f'<div class="text-red-500">Error forcing password change: {html.escape(str(e))}</div>', status_code=400) 

7400 

7401 

7402@admin_router.get("/tools", response_model=PaginatedResponse) 

7403@require_permission("tools.read", allow_admin_bypass=False) 

7404async def admin_list_tools( 

7405 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

7406 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

7407 include_inactive: bool = False, 

7408 db: Session = Depends(get_db), 

7409 user=Depends(get_current_user_with_permissions), 

7410) -> Dict[str, Any]: 

7411 """ 

7412 List tools for the admin UI with pagination support. 

7413 

7414 This endpoint retrieves a paginated list of tools from the database, optionally 

7415 including those that are inactive. Uses offset-based (page/per_page) pagination. 

7416 

7417 Args: 

7418 page (int): Page number (1-indexed). Default: 1. 

7419 per_page (int): Items per page. Default: 50. 

7420 include_inactive (bool): Whether to include inactive tools in the results. 

7421 db (Session): Database session dependency. 

7422 user (str): Authenticated user dependency. 

7423 

7424 Returns: 

7425 Dict with 'data', 'pagination', and 'links' keys containing paginated tools. 

7426 

7427 """ 

7428 LOGGER.debug(f"User {get_user_email(user)} requested tool list (page={page}, per_page={per_page})") 

7429 user_email = get_user_email(user) 

7430 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

7431 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

7432 

7433 # Call tool_service.list_tools with page-based pagination 

7434 paginated_result = await tool_service.list_tools( 

7435 db=db, 

7436 include_inactive=include_inactive, 

7437 page=page, 

7438 per_page=per_page, 

7439 user_email=user_email, 

7440 requesting_user_email=user_email, 

7441 requesting_user_is_admin=_is_admin, 

7442 requesting_user_team_roles=_team_roles, 

7443 ) 

7444 

7445 # End the read-only transaction early to avoid idle-in-transaction under load. 

7446 db.commit() 

7447 

7448 # Return standardized paginated response 

7449 return { 

7450 "data": [tool.model_dump(by_alias=True) for tool in paginated_result["data"]], 

7451 "pagination": paginated_result["pagination"].model_dump(), 

7452 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

7453 } 

7454 

7455 

7456@admin_router.get("/tools/partial", response_class=HTMLResponse) 

7457@require_permission("tools.read", allow_admin_bypass=False) 

7458async def admin_tools_partial_html( 

7459 request: Request, 

7460 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

7461 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

7462 q: str = Query("", description="Search query"), 

7463 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

7464 include_inactive: bool = False, 

7465 render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"), 

7466 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

7467 team_id: Optional[str] = Depends(_validated_team_id_param), 

7468 db: Session = Depends(get_db), 

7469 user=Depends(get_current_user_with_permissions), 

7470): 

7471 """ 

7472 Return HTML partial for paginated tools list (HTMX endpoint). 

7473 

7474 This endpoint returns only the table body rows and pagination controls 

7475 for HTMX-based pagination in the admin UI. 

7476 

7477 Args: 

7478 request (Request): FastAPI request object. 

7479 page (int): Page number (1-indexed). Default: 1. 

7480 per_page (int): Items per page. Default: 50. 

7481 q (str): Free-text query string. 

7482 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

7483 include_inactive (bool): Whether to include inactive tools in the results. 

7484 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

7485 team_id (Optional[str]): Filter by team ID. 

7486 render (str): Render mode - 'controls' returns only pagination controls. 

7487 db (Session): Database session dependency. 

7488 user (str): Authenticated user dependency. 

7489 

7490 Returns: 

7491 HTMLResponse with tools table rows and pagination controls. 

7492 """ 

7493 user_email = get_user_email(user) 

7494 search_query = _normalize_search_query(q) 

7495 normalized_tags = _normalize_tags_query(tags) 

7496 tag_groups = _parse_tag_filter_groups(normalized_tags) 

7497 LOGGER.debug(f"🔧 TOOLS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}") 

7498 

7499 # Build base query using tool_service's team filtering logic 

7500 team_ids = await _get_user_team_ids(user, db) 

7501 

7502 # Build query with eager loading for email_team to avoid N+1 queries 

7503 query = select(DbTool).options(joinedload(DbTool.email_team)) 

7504 

7505 # Apply gateway filter if provided. Support special sentinel 'null' to 

7506 # request tools with NULL gateway_id (e.g., RestTool/no gateway). 

7507 if gateway_id: 

7508 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

7509 if gateway_ids: 

7510 # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id 

7511 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

7512 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

7513 if non_null_ids and null_requested: 

7514 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) 

7515 LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL") 

7516 elif null_requested: 

7517 query = query.where(DbTool.gateway_id.is_(None)) 

7518 LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)") 

7519 else: 

7520 query = query.where(DbTool.gateway_id.in_(non_null_ids)) 

7521 LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}") 

7522 

7523 # Apply active/inactive filter 

7524 if not include_inactive: 

7525 query = query.where(DbTool.enabled.is_(True)) 

7526 

7527 # Build access conditions 

7528 # When team_id is specified, show ONLY items from that team (simpler, team-scoped view) 

7529 # When team_id is NOT specified, show all accessible items (owned + team + public) 

7530 if team_id: 

7531 # Team-specific view: only show tools from the specified team if user is a member 

7532 if team_id in team_ids: 

7533 # Apply visibility check: team/public resources + user's own resources (including private) 

7534 team_access = [ 

7535 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])), 

7536 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email), 

7537 ] 

7538 query = query.where(or_(*team_access)) 

7539 LOGGER.debug(f"Filtering tools by team_id: {team_id}") 

7540 else: 

7541 # User is not a member of this team, return no results 

7542 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

7543 query = query.where(false()) 

7544 else: 

7545 # All Teams view: apply standard access conditions 

7546 access_conditions = [] 

7547 

7548 # 1. User's personal tools (owner_email matches) 

7549 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

7550 

7551 # 2. Team tools where user is member 

7552 if team_ids: 

7553 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"]))) 

7554 

7555 # 3. Public tools 

7556 access_conditions.append(DbTool.visibility == "public") 

7557 

7558 query = query.where(or_(*access_conditions)) 

7559 

7560 if search_query: 

7561 query = query.where( 

7562 or_( 

7563 _like_contains(func.lower(DbTool.id), search_query), 

7564 _like_contains(func.lower(DbTool.original_name), search_query), 

7565 _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query), 

7566 _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query), 

7567 _like_contains(func.lower(coalesce(DbTool.description, "")), search_query), 

7568 _like_contains(func.lower(coalesce(DbTool.url, "")), search_query), 

7569 ) 

7570 ) 

7571 

7572 query = _apply_tag_filter_groups(query, db, DbTool.tags, tag_groups) 

7573 

7574 # Apply sorting: alphabetical by URL, then name, then ID (for UI display) 

7575 # Different from JSON endpoint which uses created_at DESC 

7576 query = query.order_by(DbTool.url, DbTool.original_name, DbTool.id) 

7577 

7578 # Use unified pagination function (offset-based for UI compatibility) 

7579 root_path = request.scope.get("root_path", "") 

7580 base_url = f"{root_path}/admin/tools/partial" 

7581 query_params_dict = {} 

7582 if include_inactive: 

7583 query_params_dict["include_inactive"] = "true" 

7584 if gateway_id: 

7585 query_params_dict["gateway_id"] = gateway_id 

7586 if team_id: 

7587 query_params_dict["team_id"] = team_id 

7588 if search_query: 

7589 query_params_dict["q"] = search_query 

7590 if normalized_tags: 

7591 query_params_dict["tags"] = normalized_tags 

7592 

7593 paginated_result = await paginate_query( 

7594 db=db, 

7595 query=query, 

7596 page=page, 

7597 per_page=per_page, 

7598 cursor=None, # UI uses offset pagination only 

7599 base_url=base_url, 

7600 query_params=query_params_dict, 

7601 use_cursor_threshold=False, # Disable auto-cursor switching for UI 

7602 ) 

7603 

7604 # Extract paginated tools (DbTool objects) 

7605 tools_db = paginated_result["data"] 

7606 pagination = paginated_result["pagination"] 

7607 links = paginated_result["links"] 

7608 

7609 # Team names are loaded via joinedload(DbTool.email_team) in the query 

7610 # Batch convert to Pydantic models using tool service 

7611 # This eliminates the N+1 query problem from calling get_tool() in a loop 

7612 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

7613 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

7614 tools_pydantic = [] 

7615 failed_count = 0 

7616 for t in tools_db: 

7617 try: 

7618 tools_pydantic.append( 

7619 tool_service.convert_tool_to_read( 

7620 t, 

7621 include_metrics=False, 

7622 include_auth=False, 

7623 requesting_user_email=user_email, 

7624 requesting_user_is_admin=_is_admin, 

7625 requesting_user_team_roles=_team_roles, 

7626 ) 

7627 ) 

7628 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e: 

7629 failed_count += 1 

7630 LOGGER.exception(f"Failed to convert tool {getattr(t, 'id', 'unknown')} ({getattr(t, 'name', 'unknown')}): {e}") 

7631 _adjust_pagination_for_conversion_failures(pagination, failed_count) 

7632 

7633 # Serialize tools 

7634 data = jsonable_encoder(tools_pydantic) 

7635 

7636 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

7637 db.commit() 

7638 

7639 # If render=controls, return only pagination controls 

7640 if render == "controls": 

7641 return request.app.state.templates.TemplateResponse( 

7642 request, 

7643 "pagination_controls.html", 

7644 { 

7645 "request": request, 

7646 "pagination": pagination.model_dump(), 

7647 "base_url": base_url, 

7648 "hx_target": "#tools-table-body", 

7649 "hx_indicator": "#tools-loading", 

7650 "query_params": query_params_dict, 

7651 "root_path": request.scope.get("root_path", ""), 

7652 }, 

7653 ) 

7654 

7655 # If render=selector, return tool selector items for infinite scroll 

7656 if render == "selector": 

7657 return request.app.state.templates.TemplateResponse( 

7658 request, 

7659 "tools_selector_items.html", 

7660 { 

7661 "request": request, 

7662 "data": data, 

7663 "pagination": pagination.model_dump(), 

7664 "root_path": request.scope.get("root_path", ""), 

7665 "gateway_id": gateway_id, 

7666 }, 

7667 ) 

7668 

7669 # Render template with paginated data 

7670 return request.app.state.templates.TemplateResponse( 

7671 request, 

7672 "tools_partial.html", 

7673 { 

7674 "request": request, 

7675 "data": data, 

7676 "pagination": pagination.model_dump(), 

7677 "links": links.model_dump() if links else None, 

7678 "root_path": request.scope.get("root_path", ""), 

7679 "include_inactive": include_inactive, 

7680 "query_params": query_params_dict, 

7681 "current_user_email": user_email, 

7682 "is_admin": _is_admin, 

7683 "user_team_roles": _team_roles, 

7684 }, 

7685 ) 

7686 

7687 

7688@admin_router.get("/tool-ops/partial", response_class=HTMLResponse) 

7689@require_permission("tools.read", allow_admin_bypass=False) 

7690async def admin_tool_ops_partial( 

7691 request: Request, 

7692 page: int = Query(1, ge=1, description="Page number"), 

7693 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

7694 include_inactive: bool = False, 

7695 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

7696 team_id: Optional[str] = Depends(_validated_team_id_param), 

7697 db: Session = Depends(get_db), 

7698 user=Depends(get_current_user_with_permissions), 

7699): 

7700 """ 

7701 Return HTML partial for tool operations table. 

7702 

7703 Args: 

7704 request (Request): The request object. 

7705 page (int): The page number. Defaults to 1. 

7706 per_page (int): The number of items per page. Defaults to settings.pagination_default_page_size. 

7707 include_inactive (bool): Whether to include inactive items. Defaults to False. 

7708 gateway_id (Optional[str]): The gateway ID to filter by. Defaults to None. 

7709 team_id (Optional[str]): The team ID to filter by. Defaults to None. 

7710 db (Session): The database session. Defaults to Depends(get_db). 

7711 user (Any): The current user. Defaults to Depends(get_current_user_with_permissions). 

7712 

7713 Returns: 

7714 HTMLResponse: The HTML partial for the tool operations table. 

7715 """ 

7716 user_email = get_user_email(user) 

7717 LOGGER.debug(f"Tool ops partial request - team_id: {team_id}, page: {page}") 

7718 team_ids = await _get_user_team_ids(user, db) 

7719 

7720 query = select(DbTool).options(joinedload(DbTool.email_team)) 

7721 

7722 if gateway_id: 

7723 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

7724 if gateway_ids: 

7725 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

7726 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

7727 if non_null_ids and null_requested: 

7728 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) 

7729 LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL") 

7730 elif null_requested: 

7731 query = query.where(DbTool.gateway_id.is_(None)) 

7732 LOGGER.debug("Filtering tools by NULL gateway_id (RestTool)") 

7733 else: 

7734 query = query.where(DbTool.gateway_id.in_(non_null_ids)) 

7735 LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}") 

7736 

7737 if not include_inactive: 

7738 query = query.where(DbTool.enabled.is_(True)) 

7739 

7740 if team_id: 

7741 if team_id in team_ids: 

7742 team_access = [ 

7743 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])), 

7744 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email), 

7745 ] 

7746 query = query.where(or_(*team_access)) 

7747 LOGGER.debug(f"Filtering tools by team_id: {team_id}") 

7748 else: 

7749 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

7750 query = query.where(false()) 

7751 else: 

7752 access_conditions = [] 

7753 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

7754 if team_ids: 

7755 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"]))) 

7756 access_conditions.append(DbTool.visibility == "public") 

7757 query = query.where(or_(*access_conditions)) 

7758 

7759 query = query.order_by(DbTool.url, DbTool.original_name, DbTool.id) 

7760 

7761 paginated_result = await paginate_query( 

7762 db=db, 

7763 query=query, 

7764 page=page, 

7765 per_page=per_page, 

7766 cursor=None, 

7767 base_url=f"{request.scope.get('root_path', '')}/admin/tool-ops/partial", 

7768 query_params={ 

7769 "include_inactive": "true" if include_inactive else "false", 

7770 "gateway_id": gateway_id or "", 

7771 "team_id": team_id or "", 

7772 }, 

7773 use_cursor_threshold=False, 

7774 ) 

7775 

7776 tools_db = paginated_result["data"] 

7777 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

7778 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

7779 tools_pydantic = [ 

7780 tool_service.convert_tool_to_read( 

7781 t, 

7782 include_metrics=False, 

7783 include_auth=False, 

7784 requesting_user_email=user_email, 

7785 requesting_user_is_admin=_is_admin, 

7786 requesting_user_team_roles=_team_roles, 

7787 ) 

7788 for t in tools_db 

7789 ] 

7790 db.commit() 

7791 

7792 return request.app.state.templates.TemplateResponse( 

7793 request, 

7794 "toolops_partial.html", 

7795 { 

7796 "request": request, 

7797 "tools": tools_pydantic, 

7798 "root_path": request.scope.get("root_path", ""), 

7799 "current_user_email": user_email, 

7800 "is_admin": _is_admin, 

7801 "user_team_roles": _team_roles, 

7802 }, 

7803 ) 

7804 

7805 

7806@admin_router.get("/tools/ids", response_class=JSONResponse) 

7807@require_permission("tools.read", allow_admin_bypass=False) 

7808async def admin_get_all_tool_ids( 

7809 include_inactive: bool = False, 

7810 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

7811 team_id: Optional[str] = Depends(_validated_team_id_param), 

7812 db: Session = Depends(get_db), 

7813 user=Depends(get_current_user_with_permissions), 

7814): 

7815 """ 

7816 Return all tool IDs accessible to the current user. 

7817 

7818 This is used by "Select All" to get all tool IDs without loading full data. 

7819 

7820 Args: 

7821 include_inactive (bool): Whether to include inactive tools in the results 

7822 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local tools). 

7823 team_id (Optional[str]): Filter by team ID. 

7824 db (Session): Database session dependency 

7825 user: Current user making the request 

7826 

7827 Returns: 

7828 JSONResponse: List of tool IDs accessible to the user 

7829 """ 

7830 user_email = get_user_email(user) 

7831 

7832 # Build base query 

7833 team_ids = await _get_user_team_ids(user, db) 

7834 

7835 query = select(DbTool.id) 

7836 

7837 if not include_inactive: 

7838 query = query.where(DbTool.enabled.is_(True)) 

7839 

7840 # Apply optional gateway/server scoping (comma-separated ids). Accepts the 

7841 # literal value 'null' to indicate NULL gateway_id (local tools). 

7842 if gateway_id: 

7843 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

7844 if gateway_ids: 

7845 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

7846 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

7847 if non_null_ids and null_requested: 

7848 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) 

7849 LOGGER.debug(f"Filtering tools by gateway IDs (including NULL): {non_null_ids} + NULL") 

7850 elif null_requested: 

7851 query = query.where(DbTool.gateway_id.is_(None)) 

7852 LOGGER.debug("Filtering tools by NULL gateway_id (local tools)") 

7853 else: 

7854 query = query.where(DbTool.gateway_id.in_(non_null_ids)) 

7855 LOGGER.debug(f"Filtering tools by gateway IDs: {non_null_ids}") 

7856 

7857 # Build access conditions 

7858 # When team_id is specified, show ONLY items from that team (team-scoped view) 

7859 # Otherwise, show all accessible items (All Teams view) 

7860 if team_id: 

7861 if team_id in team_ids: 

7862 # Apply visibility check: team/public resources + user's own resources (including private) 

7863 team_access = [ 

7864 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])), 

7865 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email), 

7866 ] 

7867 query = query.where(or_(*team_access)) 

7868 LOGGER.debug(f"Filtering tool IDs by team_id: {team_id}") 

7869 else: 

7870 LOGGER.warning(f"User {user_email} attempted to filter tool IDs by team {team_id} but is not a member") 

7871 query = query.where(false()) 

7872 else: 

7873 # All Teams view: apply standard access conditions (owner, team, public) 

7874 access_conditions = [] 

7875 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

7876 access_conditions.append(DbTool.visibility == "public") 

7877 if team_ids: 

7878 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"]))) 

7879 query = query.where(or_(*access_conditions)) 

7880 

7881 # Get all IDs 

7882 tool_ids = [row[0] for row in db.execute(query).all()] 

7883 

7884 return {"tool_ids": tool_ids, "count": len(tool_ids)} 

7885 

7886 

7887@admin_router.get("/tools/search", response_class=JSONResponse) 

7888@require_permission("tools.read", allow_admin_bypass=False) 

7889async def admin_search_tools( 

7890 q: str = Query("", description="Search query"), 

7891 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

7892 include_inactive: bool = False, 

7893 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Maximum number of results to return"), 

7894 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

7895 team_id: Optional[str] = Depends(_validated_team_id_param), 

7896 db: Session = Depends(get_db), 

7897 user=Depends(get_current_user_with_permissions), 

7898): 

7899 """ 

7900 Search tools by name, ID, or description. 

7901 

7902 This endpoint searches tools across all accessible tools for the current user, 

7903 returning both IDs and names for use in search functionality like the Add Server page. 

7904 

7905 Args: 

7906 q (str): Search query string to match against tool names, IDs, or descriptions. 

7907 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

7908 include_inactive (bool): Whether to include inactive tools in the search results. 

7909 limit (int): Maximum number of results to return. 

7910 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

7911 team_id (Optional[str]): Filter by team ID. 

7912 db (Session): Database session. 

7913 user: Current user with permissions. 

7914 

7915 Returns: 

7916 JSONResponse: A JSON response containing a list of matching tools. 

7917 """ 

7918 user_email = get_user_email(user) 

7919 search_query = _normalize_search_query(q) 

7920 normalized_tags = _normalize_tags_query(tags) 

7921 tag_groups = _parse_tag_filter_groups(normalized_tags) 

7922 

7923 if not search_query and not tag_groups: 

7924 return _build_search_response(entity_key="tools", entity_type="tools", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

7925 

7926 # Build base query 

7927 team_ids = await _get_user_team_ids(user, db) 

7928 

7929 query = select(DbTool.id, DbTool.original_name, DbTool.custom_name, DbTool.display_name, DbTool.description) 

7930 

7931 # Apply gateway filter if provided. Support special sentinel 'null' to 

7932 # request tools with NULL gateway_id (e.g., RestTool/no gateway). 

7933 if gateway_id: 

7934 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

7935 if gateway_ids: 

7936 # Treat literal 'null' (case-insensitive) as a request for NULL gateway_id 

7937 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

7938 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

7939 if non_null_ids and null_requested: 

7940 query = query.where(or_(DbTool.gateway_id.in_(non_null_ids), DbTool.gateway_id.is_(None))) 

7941 LOGGER.debug(f"Filtering tool search by gateway IDs (including NULL): {non_null_ids} + NULL") 

7942 elif null_requested: 

7943 query = query.where(DbTool.gateway_id.is_(None)) 

7944 LOGGER.debug("Filtering tool search by NULL gateway_id (RestTool)") 

7945 else: 

7946 query = query.where(DbTool.gateway_id.in_(non_null_ids)) 

7947 LOGGER.debug(f"Filtering tool search by gateway IDs: {non_null_ids}") 

7948 

7949 if not include_inactive: 

7950 query = query.where(DbTool.enabled.is_(True)) 

7951 

7952 # Build access conditions 

7953 # When team_id is specified, show ONLY items from that team (team-scoped view) 

7954 # Otherwise, show all accessible items (All Teams view) 

7955 if team_id: 

7956 if team_id in team_ids: 

7957 # Apply visibility check: team/public resources + user's own resources (including private) 

7958 team_access = [ 

7959 and_(DbTool.team_id == team_id, DbTool.visibility.in_(["team", "public"])), 

7960 and_(DbTool.team_id == team_id, DbTool.owner_email == user_email), 

7961 ] 

7962 query = query.where(or_(*team_access)) 

7963 LOGGER.debug(f"Filtering tool search by team_id: {team_id}") 

7964 else: 

7965 LOGGER.warning(f"User {user_email} attempted to filter tool search by team {team_id} but is not a member") 

7966 query = query.where(false()) 

7967 else: 

7968 # All Teams view: apply standard access conditions (owner, team, public) 

7969 access_conditions = [] 

7970 access_conditions.append(_owner_access_condition(DbTool.owner_email, DbTool.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

7971 access_conditions.append(DbTool.visibility == "public") 

7972 if team_ids: 

7973 access_conditions.append(and_(DbTool.team_id.in_(team_ids), DbTool.visibility.in_(["team", "public"]))) 

7974 query = query.where(or_(*access_conditions)) 

7975 

7976 # Add search conditions - search in display fields and description 

7977 # Using the same priority as display: displayName -> customName -> original_name 

7978 if search_query: 

7979 search_conditions = [ 

7980 _like_contains(func.lower(DbTool.id), search_query), 

7981 _like_contains(func.lower(DbTool.original_name), search_query), 

7982 _like_contains(func.lower(coalesce(DbTool.display_name, "")), search_query), 

7983 _like_contains(func.lower(coalesce(DbTool.custom_name, "")), search_query), 

7984 _like_contains(func.lower(coalesce(DbTool.description, "")), search_query), 

7985 _like_contains(func.lower(coalesce(DbTool.url, "")), search_query), 

7986 ] 

7987 query = query.where(or_(*search_conditions)) 

7988 

7989 query = _apply_tag_filter_groups(query, db, DbTool.tags, tag_groups) 

7990 

7991 # Order by relevance - prioritize matches at start of names 

7992 if search_query: 

7993 query = query.order_by( 

7994 case( 

7995 (func.lower(DbTool.original_name).startswith(search_query), 1), 

7996 (func.lower(coalesce(DbTool.custom_name, "")).startswith(search_query), 1), 

7997 (func.lower(coalesce(DbTool.display_name, "")).startswith(search_query), 1), 

7998 else_=2, 

7999 ), 

8000 func.lower(DbTool.original_name), 

8001 ) 

8002 else: 

8003 query = query.order_by(func.lower(DbTool.original_name)) 

8004 query = query.limit(limit) 

8005 

8006 # Execute query 

8007 results = db.execute(query).all() 

8008 

8009 # Format results 

8010 tools = [] 

8011 for row in results: 

8012 tools.append({"id": row.id, "name": row.original_name, "display_name": row.display_name, "custom_name": row.custom_name}) # original_name for search matching 

8013 

8014 return _build_search_response(entity_key="tools", entity_type="tools", items=tools, query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

8015 

8016 

8017@admin_router.get("/prompts/partial", response_class=HTMLResponse) 

8018@require_permission("prompts.read", allow_admin_bypass=False) 

8019async def admin_prompts_partial_html( 

8020 request: Request, 

8021 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

8022 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

8023 q: str = Query("", description="Search query"), 

8024 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

8025 include_inactive: bool = False, 

8026 render: Optional[str] = Query(None), 

8027 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

8028 team_id: Optional[str] = Depends(_validated_team_id_param), 

8029 db: Session = Depends(get_db), 

8030 user=Depends(get_current_user_with_permissions), 

8031): 

8032 """Return paginated prompts HTML partials for the admin UI. 

8033 

8034 This HTMX endpoint returns only the partial HTML used by the admin UI for 

8035 prompts. It supports three render modes: 

8036 

8037 - default: full table partial (rows + controls) 

8038 - ``render="controls"``: return only pagination controls 

8039 - ``render="selector"``: return selector items for infinite scroll 

8040 

8041 Args: 

8042 request (Request): FastAPI request object used by the template engine. 

8043 page (int): Page number (1-indexed). 

8044 per_page (int): Number of items per page (bounded by settings). 

8045 q (str): Free-text query string. 

8046 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

8047 include_inactive (bool): If True, include inactive prompts in results. 

8048 render (Optional[str]): Render mode; one of None, "controls", "selector". 

8049 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

8050 team_id (Optional[str]): Filter by team ID. 

8051 db (Session): Database session (dependency-injected). 

8052 user: Authenticated user object from dependency injection. 

8053 

8054 Returns: 

8055 Union[HTMLResponse, TemplateResponse]: A rendered template response 

8056 containing either the table partial, pagination controls, or selector 

8057 items depending on ``render``. The response contains JSON-serializable 

8058 encoded prompt data when templates expect it. 

8059 """ 

8060 LOGGER.debug( 

8061 f"User {get_user_email(user)} requested prompts HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id}, team_id={team_id})" 

8062 ) 

8063 search_query = _normalize_search_query(q) 

8064 normalized_tags = _normalize_tags_query(tags) 

8065 tag_groups = _parse_tag_filter_groups(normalized_tags) 

8066 # Normalize per_page within configured bounds 

8067 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) 

8068 

8069 user_email = get_user_email(user) 

8070 

8071 # Team scoping 

8072 team_ids = await _get_user_team_ids(user, db) 

8073 

8074 # Build base query 

8075 query = select(DbPrompt) 

8076 

8077 # Apply gateway filter if provided 

8078 if gateway_id: 

8079 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

8080 if gateway_ids: 

8081 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

8082 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

8083 if non_null_ids and null_requested: 

8084 query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None))) 

8085 LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL") 

8086 elif null_requested: 

8087 query = query.where(DbPrompt.gateway_id.is_(None)) 

8088 LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)") 

8089 else: 

8090 query = query.where(DbPrompt.gateway_id.in_(non_null_ids)) 

8091 LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}") 

8092 

8093 if not include_inactive: 

8094 query = query.where(DbPrompt.enabled.is_(True)) 

8095 

8096 # Build access conditions 

8097 # When team_id is specified, show ONLY items from that team (team-scoped view) 

8098 # Otherwise, show all accessible items (All Teams view) 

8099 if team_id: 

8100 # Team-specific view: only show prompts from the specified team 

8101 if team_id in team_ids: 

8102 # Apply visibility check: team/public resources + user's own resources (including private) 

8103 team_access = [ 

8104 and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])), 

8105 and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email), 

8106 ] 

8107 query = query.where(or_(*team_access)) 

8108 LOGGER.debug(f"Filtering prompts by team_id: {team_id}") 

8109 else: 

8110 # User is not a member of this team, return no results using SQLAlchemy's false() 

8111 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

8112 query = query.where(false()) 

8113 else: 

8114 # All Teams view: apply standard access conditions (owner, team, public) 

8115 access_conditions = [] 

8116 access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8117 if team_ids: 

8118 access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"]))) 

8119 access_conditions.append(DbPrompt.visibility == "public") 

8120 query = query.where(or_(*access_conditions)) 

8121 

8122 if search_query: 

8123 query = query.where( 

8124 or_( 

8125 _like_contains(func.lower(DbPrompt.id), search_query), 

8126 _like_contains(func.lower(DbPrompt.original_name), search_query), 

8127 _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query), 

8128 _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query), 

8129 ) 

8130 ) 

8131 

8132 query = _apply_tag_filter_groups(query, db, DbPrompt.tags, tag_groups) 

8133 

8134 # Apply pagination ordering for cursor support 

8135 query = query.order_by(desc(DbPrompt.created_at), desc(DbPrompt.id)) 

8136 

8137 # Build query params for pagination links 

8138 query_params = {} 

8139 if include_inactive: 

8140 query_params["include_inactive"] = "true" 

8141 if gateway_id: 

8142 query_params["gateway_id"] = gateway_id 

8143 if team_id: 

8144 query_params["team_id"] = team_id 

8145 if search_query: 

8146 query_params["q"] = search_query 

8147 if normalized_tags: 

8148 query_params["tags"] = normalized_tags 

8149 

8150 # Use unified pagination function 

8151 root_path = request.scope.get("root_path", "") 

8152 base_url = f"{root_path}/admin/prompts/partial" 

8153 paginated_result = await paginate_query( 

8154 db=db, 

8155 query=query, 

8156 page=page, 

8157 per_page=per_page, 

8158 cursor=None, # HTMX partials use page-based navigation 

8159 base_url=base_url, 

8160 query_params=query_params, 

8161 use_cursor_threshold=False, # Disable auto-cursor switching for UI 

8162 ) 

8163 

8164 # Extract paginated prompts (DbPrompt objects) 

8165 prompts_db = paginated_result["data"] 

8166 pagination = paginated_result["pagination"] 

8167 links = paginated_result["links"] 

8168 

8169 # Batch fetch team names for the prompts to avoid N+1 queries 

8170 team_ids_set = {p.team_id for p in prompts_db if p.team_id} 

8171 team_map = {} 

8172 if team_ids_set: 

8173 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all() 

8174 team_map = {team.id: team.name for team in teams} 

8175 

8176 # Apply team names to DB objects before conversion 

8177 for p in prompts_db: 

8178 p.team = team_map.get(p.team_id) if p.team_id else None 

8179 

8180 # Batch convert to Pydantic models using prompt service 

8181 # This eliminates the N+1 query problem from calling get_prompt_details() in a loop 

8182 prompts_pydantic = [] 

8183 failed_count = 0 

8184 for p in prompts_db: 

8185 try: 

8186 prompts_pydantic.append(prompt_service.convert_prompt_to_read(p, include_metrics=False)) 

8187 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e: 

8188 failed_count += 1 

8189 LOGGER.exception(f"Failed to convert prompt {getattr(p, 'id', 'unknown')} ({getattr(p, 'name', 'unknown')}): {e}") 

8190 _adjust_pagination_for_conversion_failures(pagination, failed_count) 

8191 

8192 data = jsonable_encoder(prompts_pydantic) 

8193 

8194 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

8195 db.commit() 

8196 

8197 if render == "controls": 

8198 return request.app.state.templates.TemplateResponse( 

8199 request, 

8200 "pagination_controls.html", 

8201 { 

8202 "request": request, 

8203 "pagination": pagination.model_dump(), 

8204 "base_url": base_url, 

8205 "hx_target": "#prompts-table-body", 

8206 "hx_indicator": "#prompts-loading", 

8207 "query_params": query_params, 

8208 "root_path": request.scope.get("root_path", ""), 

8209 }, 

8210 ) 

8211 

8212 if render == "selector": 

8213 return request.app.state.templates.TemplateResponse( 

8214 request, 

8215 "prompts_selector_items.html", 

8216 { 

8217 "request": request, 

8218 "data": data, 

8219 "pagination": pagination.model_dump(), 

8220 "root_path": request.scope.get("root_path", ""), 

8221 "gateway_id": gateway_id, 

8222 }, 

8223 ) 

8224 

8225 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

8226 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

8227 return request.app.state.templates.TemplateResponse( 

8228 request, 

8229 "prompts_partial.html", 

8230 { 

8231 "request": request, 

8232 "data": data, 

8233 "pagination": pagination.model_dump(), 

8234 "links": links.model_dump() if links else None, 

8235 "root_path": request.scope.get("root_path", ""), 

8236 "include_inactive": include_inactive, 

8237 "query_params": query_params, 

8238 "current_user_email": user_email, 

8239 "is_admin": _is_admin, 

8240 "user_team_roles": _team_roles, 

8241 }, 

8242 ) 

8243 

8244 

8245@admin_router.get("/gateways/partial", response_class=HTMLResponse) 

8246@require_permission("gateways.read", allow_admin_bypass=False) 

8247async def admin_gateways_partial_html( 

8248 request: Request, 

8249 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

8250 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

8251 q: str = Query("", description="Search query"), 

8252 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

8253 include_inactive: bool = False, 

8254 render: Optional[str] = Query(None), 

8255 team_id: Optional[str] = Depends(_validated_team_id_param), 

8256 db: Session = Depends(get_db), 

8257 user=Depends(get_current_user_with_permissions), 

8258): 

8259 """Return paginated gateways HTML partials for the admin UI. 

8260 

8261 This HTMX endpoint returns only the partial HTML used by the admin UI for 

8262 gateways. It supports three render modes: 

8263 

8264 - default: full table partial (rows + controls) 

8265 - ``render="controls"``: return only pagination controls 

8266 - ``render="selector"``: return selector items for infinite scroll 

8267 

8268 Args: 

8269 request (Request): FastAPI request object used by the template engine. 

8270 page (int): Page number (1-indexed). 

8271 per_page (int): Number of items per page (bounded by settings). 

8272 q (str): Free-text query string. 

8273 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

8274 include_inactive (bool): If True, include inactive gateways in results. 

8275 render (Optional[str]): Render mode; one of None, "controls", "selector". 

8276 team_id (Optional[str]): Filter by team ID. 

8277 db (Session): Database session (dependency-injected). 

8278 user: Authenticated user object from dependency injection. 

8279 

8280 Returns: 

8281 Union[HTMLResponse, TemplateResponse]: A rendered template response 

8282 containing either the table partial, pagination controls, or selector 

8283 items depending on ``render``. The response contains JSON-serializable 

8284 encoded gateway data when templates expect it. 

8285 """ 

8286 user_email = get_user_email(user) 

8287 search_query = _normalize_search_query(q) 

8288 normalized_tags = _normalize_tags_query(tags) 

8289 tag_groups = _parse_tag_filter_groups(normalized_tags) 

8290 LOGGER.debug(f"🔷 GATEWAYS PARTIAL REQUEST - User: {user_email}, team_id: {team_id}, page: {page}, render: {render}, referer: {request.headers.get('referer', 'none')}") 

8291 # Normalize per_page within configured bounds 

8292 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) 

8293 

8294 # Team scoping 

8295 team_ids = await _get_user_team_ids(user, db) 

8296 

8297 # Build base query 

8298 query = select(DbGateway).options(joinedload(DbGateway.email_team)) 

8299 

8300 if not include_inactive: 

8301 query = query.where(DbGateway.enabled.is_(True)) 

8302 

8303 # Build access conditions 

8304 # When team_id is specified, show ONLY items from that team (simpler, team-scoped view) 

8305 # When team_id is NOT specified, show all accessible items (owned + team + public) 

8306 if team_id: 

8307 # Team-specific view: only show gateways from the specified team if user is a member 

8308 if team_id in team_ids: 

8309 # Apply visibility check: team/public resources + user's own resources (including private) 

8310 team_access = [ 

8311 and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])), 

8312 and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email), 

8313 ] 

8314 query = query.where(or_(*team_access)) 

8315 LOGGER.debug(f"Filtering gateways by team_id: {team_id}") 

8316 else: 

8317 # User is not a member of this team, return no results 

8318 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

8319 query = query.where(false()) 

8320 else: 

8321 # All Teams view: apply standard access conditions 

8322 access_conditions = [] 

8323 access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8324 if team_ids: 

8325 access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"]))) 

8326 access_conditions.append(DbGateway.visibility == "public") 

8327 

8328 query = query.where(or_(*access_conditions)) 

8329 

8330 if search_query: 

8331 query = query.where( 

8332 or_( 

8333 _like_contains(func.lower(DbGateway.id), search_query), 

8334 _like_contains(func.lower(DbGateway.name), search_query), 

8335 _like_contains(func.lower(coalesce(DbGateway.url, "")), search_query), 

8336 _like_contains(func.lower(coalesce(DbGateway.description, "")), search_query), 

8337 ) 

8338 ) 

8339 

8340 query = _apply_tag_filter_groups(query, db, DbGateway.tags, tag_groups) 

8341 

8342 # Apply pagination ordering for cursor support 

8343 query = query.order_by(desc(DbGateway.created_at), desc(DbGateway.id)) 

8344 

8345 # Build query params for pagination links 

8346 query_params = {} 

8347 if include_inactive: 

8348 query_params["include_inactive"] = "true" 

8349 if team_id: 

8350 query_params["team_id"] = team_id 

8351 if search_query: 

8352 query_params["q"] = search_query 

8353 if normalized_tags: 

8354 query_params["tags"] = normalized_tags 

8355 

8356 # Use unified pagination function 

8357 root_path = request.scope.get("root_path", "") 

8358 base_url = f"{root_path}/admin/gateways/partial" 

8359 paginated_result = await paginate_query( 

8360 db=db, 

8361 query=query, 

8362 page=page, 

8363 per_page=per_page, 

8364 cursor=None, # HTMX partials use page-based navigation 

8365 base_url=base_url, 

8366 query_params=query_params, 

8367 use_cursor_threshold=False, # Disable auto-cursor switching for UI 

8368 ) 

8369 

8370 # Extract paginated gateways (DbGateway objects) 

8371 gateways_db = paginated_result["data"] 

8372 pagination = paginated_result["pagination"] 

8373 links = paginated_result["links"] 

8374 

8375 # Batch convert to Pydantic models using gateway service 

8376 # This eliminates the N+1 query problem from calling get_gateway_details() in a loop 

8377 gateways_pydantic = [] 

8378 failed_count = 0 

8379 for g in gateways_db: 

8380 try: 

8381 gateways_pydantic.append(gateway_service.convert_gateway_to_read(g)) 

8382 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e: 

8383 failed_count += 1 

8384 LOGGER.exception(f"Failed to convert gateway {getattr(g, 'id', 'unknown')} ({getattr(g, 'name', 'unknown')}): {e}") 

8385 _adjust_pagination_for_conversion_failures(pagination, failed_count) 

8386 data = jsonable_encoder(gateways_pydantic) 

8387 

8388 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

8389 db.commit() 

8390 

8391 LOGGER.info(f"🔷 GATEWAYS PARTIAL RESPONSE - Returning {len(data)} gateways, render mode: {render or 'default'}, team_id used in query: {team_id}") 

8392 

8393 if render == "controls": 

8394 return request.app.state.templates.TemplateResponse( 

8395 request, 

8396 "pagination_controls.html", 

8397 { 

8398 "request": request, 

8399 "pagination": pagination.model_dump(), 

8400 "base_url": base_url, 

8401 "hx_target": "#gateways-table-body", 

8402 "hx_indicator": "#gateways-loading", 

8403 "query_params": query_params, 

8404 "root_path": request.scope.get("root_path", ""), 

8405 }, 

8406 ) 

8407 

8408 if render == "selector": 

8409 return request.app.state.templates.TemplateResponse( 

8410 request, 

8411 "gateways_selector_items.html", 

8412 {"request": request, "data": data, "pagination": pagination.model_dump(), "root_path": request.scope.get("root_path", "")}, 

8413 ) 

8414 

8415 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

8416 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

8417 return request.app.state.templates.TemplateResponse( 

8418 request, 

8419 "gateways_partial.html", 

8420 { 

8421 "request": request, 

8422 "data": data, 

8423 "pagination": pagination.model_dump(), 

8424 "links": links.model_dump() if links else None, 

8425 "root_path": request.scope.get("root_path", ""), 

8426 "include_inactive": include_inactive, 

8427 "query_params": query_params, 

8428 "current_user_email": user_email, 

8429 "is_admin": _is_admin, 

8430 "user_team_roles": _team_roles, 

8431 }, 

8432 ) 

8433 

8434 

8435@admin_router.get("/gateways/ids", response_class=JSONResponse) 

8436@require_permission("gateways.read", allow_admin_bypass=False) 

8437async def admin_get_all_gateways_ids( 

8438 include_inactive: bool = False, 

8439 team_id: Optional[str] = Depends(_validated_team_id_param), 

8440 db: Session = Depends(get_db), 

8441 user=Depends(get_current_user_with_permissions), 

8442): 

8443 """Return all gateway IDs accessible to the current user (select-all helper). 

8444 

8445 This endpoint is used by UI "Select All" helpers to fetch only the IDs 

8446 of gateways the requesting user can access (owner, team, or public). 

8447 

8448 Args: 

8449 include_inactive (bool): When True include prompts that are inactive. 

8450 team_id (Optional[str]): Filter by team ID. 

8451 db (Session): Database session (injected dependency). 

8452 user: Authenticated user object from dependency injection. 

8453 

8454 Returns: 

8455 dict: A dictionary containing two keys: 

8456 - "prompt_ids": List[str] of accessible prompt IDs. 

8457 - "count": int number of IDs returned. 

8458 """ 

8459 user_email = get_user_email(user) 

8460 team_ids = await _get_user_team_ids(user, db) 

8461 

8462 query = select(DbGateway.id) 

8463 

8464 if not include_inactive: 

8465 query = query.where(DbGateway.enabled.is_(True)) 

8466 

8467 # Build access conditions 

8468 # When team_id is specified, show ONLY items from that team (team-scoped view) 

8469 # Otherwise, show all accessible items (All Teams view) 

8470 if team_id: 

8471 if team_id in team_ids: 

8472 # Apply visibility check: team/public resources + user's own resources (including private) 

8473 team_access = [ 

8474 and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])), 

8475 and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email), 

8476 ] 

8477 query = query.where(or_(*team_access)) 

8478 LOGGER.debug(f"Filtering gateway IDs by team_id: {team_id}") 

8479 else: 

8480 LOGGER.warning(f"User {user_email} attempted to filter gateway IDs by team {team_id} but is not a member") 

8481 query = query.where(false()) 

8482 else: 

8483 # All Teams view: apply standard access conditions (owner, team, public) 

8484 access_conditions = [] 

8485 access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8486 access_conditions.append(DbGateway.visibility == "public") 

8487 if team_ids: 

8488 access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"]))) 

8489 query = query.where(or_(*access_conditions)) 

8490 

8491 gateway_ids = [row[0] for row in db.execute(query).all()] 

8492 return {"gateway_ids": gateway_ids, "count": len(gateway_ids)} 

8493 

8494 

8495@admin_router.get("/gateways/search", response_class=JSONResponse) 

8496@require_permission("gateways.read", allow_admin_bypass=False) 

8497async def admin_search_gateways( 

8498 q: str = Query("", description="Search query"), 

8499 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

8500 include_inactive: bool = False, 

8501 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size), 

8502 team_id: Optional[str] = Depends(_validated_team_id_param), 

8503 db: Session = Depends(get_db), 

8504 user=Depends(get_current_user_with_permissions), 

8505): 

8506 """Search gateways by name or description for selector search. 

8507 

8508 Performs a case-insensitive search over prompt names and descriptions 

8509 and returns a limited list of matching gateways suitable for selector 

8510 UIs (id, name, description). 

8511 

8512 Args: 

8513 q (str): Search query string. 

8514 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

8515 include_inactive (bool): When True include gateways that are inactive. 

8516 limit (int): Maximum number of results to return (bounded by the query parameter). 

8517 team_id (Optional[str]): Filter by team ID. 

8518 db (Session): Database session (injected dependency). 

8519 user: Authenticated user object from dependency injection. 

8520 

8521 Returns: 

8522 dict: A dictionary containing: 

8523 - "gateways": List[dict] where each dict has keys "id", "name", "description". 

8524 - "count": int number of matched gateways returned. 

8525 """ 

8526 user_email = get_user_email(user) 

8527 search_query = _normalize_search_query(q) 

8528 normalized_tags = _normalize_tags_query(tags) 

8529 tag_groups = _parse_tag_filter_groups(normalized_tags) 

8530 if not search_query and not tag_groups: 

8531 return _build_search_response(entity_key="gateways", entity_type="gateways", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

8532 

8533 team_ids = await _get_user_team_ids(user, db) 

8534 

8535 query = select(DbGateway.id, DbGateway.name, DbGateway.url, DbGateway.description) 

8536 

8537 if not include_inactive: 

8538 query = query.where(DbGateway.enabled.is_(True)) 

8539 

8540 # Build access conditions 

8541 # When team_id is specified, show ONLY items from that team (team-scoped view) 

8542 # Otherwise, show all accessible items (All Teams view) 

8543 if team_id: 

8544 if team_id in team_ids: 

8545 # Apply visibility check: team/public resources + user's own resources (including private) 

8546 team_access = [ 

8547 and_(DbGateway.team_id == team_id, DbGateway.visibility.in_(["team", "public"])), 

8548 and_(DbGateway.team_id == team_id, DbGateway.owner_email == user_email), 

8549 ] 

8550 query = query.where(or_(*team_access)) 

8551 LOGGER.debug(f"Filtering gateway search by team_id: {team_id}") 

8552 else: 

8553 LOGGER.warning(f"User {user_email} attempted to filter gateway search by team {team_id} but is not a member") 

8554 query = query.where(false()) 

8555 else: 

8556 # All Teams view: apply standard access conditions (owner, team, public) 

8557 access_conditions = [] 

8558 access_conditions.append(_owner_access_condition(DbGateway.owner_email, DbGateway.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8559 access_conditions.append(DbGateway.visibility == "public") 

8560 if team_ids: 

8561 access_conditions.append(and_(DbGateway.team_id.in_(team_ids), DbGateway.visibility.in_(["team", "public"]))) 

8562 query = query.where(or_(*access_conditions)) 

8563 

8564 if search_query: 

8565 search_conditions = [ 

8566 _like_contains(func.lower(DbGateway.id), search_query), 

8567 _like_contains(func.lower(DbGateway.name), search_query), 

8568 _like_contains(func.lower(coalesce(DbGateway.url, "")), search_query), 

8569 _like_contains(func.lower(coalesce(DbGateway.description, "")), search_query), 

8570 ] 

8571 query = query.where(or_(*search_conditions)) 

8572 

8573 query = _apply_tag_filter_groups(query, db, DbGateway.tags, tag_groups) 

8574 

8575 if search_query: 

8576 query = query.order_by( 

8577 case( 

8578 (func.lower(DbGateway.name).startswith(search_query), 1), 

8579 (func.lower(coalesce(DbGateway.url, "")).startswith(search_query), 1), 

8580 else_=2, 

8581 ), 

8582 func.lower(DbGateway.name), 

8583 ) 

8584 else: 

8585 query = query.order_by(func.lower(DbGateway.name)) 

8586 query = query.limit(limit) 

8587 

8588 results = db.execute(query).all() 

8589 gateways = [] 

8590 for row in results: 

8591 gateways.append( 

8592 { 

8593 "id": row.id, 

8594 "name": row.name, 

8595 "url": row.url, 

8596 "description": row.description, 

8597 } 

8598 ) 

8599 

8600 return _build_search_response(entity_key="gateways", entity_type="gateways", items=gateways, query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

8601 

8602 

8603@admin_router.get("/servers/ids", response_class=JSONResponse) 

8604@require_permission("servers.read", allow_admin_bypass=False) 

8605async def admin_get_all_server_ids( 

8606 include_inactive: bool = False, 

8607 team_id: Optional[str] = Depends(_validated_team_id_param), 

8608 db: Session = Depends(get_db), 

8609 user=Depends(get_current_user_with_permissions), 

8610): 

8611 """Return all server IDs accessible to the current user (select-all helper). 

8612 

8613 This endpoint is used by UI "Select All" helpers to fetch only the IDs 

8614 of servers the requesting user can access (owner, team, or public). 

8615 

8616 Args: 

8617 include_inactive (bool): When True include servers that are inactive. 

8618 team_id (Optional[str]): Filter by team ID. 

8619 db (Session): Database session (injected dependency). 

8620 user: Authenticated user object from dependency injection. 

8621 

8622 Returns: 

8623 dict: A dictionary containing two keys: 

8624 - "server_ids": List[str] of accessible server IDs. 

8625 - "count": int number of IDs returned. 

8626 """ 

8627 user_email = get_user_email(user) 

8628 team_ids = await _get_user_team_ids(user, db) 

8629 

8630 query = select(DbServer.id) 

8631 

8632 if not include_inactive: 

8633 query = query.where(DbServer.enabled.is_(True)) 

8634 

8635 # Build access conditions 

8636 # When team_id is specified, show ONLY items from that team (team-scoped view) 

8637 # Otherwise, show all accessible items (All Teams view) 

8638 if team_id: 

8639 # Team-specific view: only show servers from the specified team 

8640 if team_id in team_ids: 

8641 # Apply visibility check: team/public resources + user's own resources (including private) 

8642 team_access = [ 

8643 and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])), 

8644 and_(DbServer.team_id == team_id, DbServer.owner_email == user_email), 

8645 ] 

8646 query = query.where(or_(*team_access)) 

8647 LOGGER.debug(f"Filtering server IDs by team_id: {team_id}") 

8648 else: 

8649 # User is not a member of this team, return no results using SQLAlchemy's false() 

8650 LOGGER.warning(f"User {user_email} attempted to filter server IDs by team {team_id} but is not a member") 

8651 query = query.where(false()) 

8652 else: 

8653 # All Teams view: apply standard access conditions (owner, team, public) 

8654 access_conditions = [] 

8655 access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8656 if team_ids: 

8657 access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"]))) 

8658 access_conditions.append(DbServer.visibility == "public") 

8659 query = query.where(or_(*access_conditions)) 

8660 

8661 server_ids = [row[0] for row in db.execute(query).all()] 

8662 return {"server_ids": server_ids, "count": len(server_ids)} 

8663 

8664 

8665@admin_router.get("/servers/search", response_class=JSONResponse) 

8666@require_permission("servers.read", allow_admin_bypass=False) 

8667async def admin_search_servers( 

8668 q: str = Query("", description="Search query"), 

8669 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

8670 include_inactive: bool = False, 

8671 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size), 

8672 team_id: Optional[str] = Depends(_validated_team_id_param), 

8673 db: Session = Depends(get_db), 

8674 user=Depends(get_current_user_with_permissions), 

8675): 

8676 """Search servers by name or description for selector search. 

8677 

8678 Performs a case-insensitive search over prompt names and descriptions 

8679 and returns a limited list of matching servers suitable for selector 

8680 UIs (id, name, description). 

8681 

8682 Args: 

8683 q (str): Search query string. 

8684 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

8685 include_inactive (bool): When True include servers that are inactive. 

8686 limit (int): Maximum number of results to return (bounded by the query parameter). 

8687 team_id (Optional[str]): Filter by team ID. 

8688 db (Session): Database session (injected dependency). 

8689 user: Authenticated user object from dependency injection. 

8690 

8691 Returns: 

8692 dict: A dictionary containing: 

8693 - "servers": List[dict] where each dict has keys "id", "name", "description". 

8694 - "count": int number of matched servers returned. 

8695 """ 

8696 user_email = get_user_email(user) 

8697 search_query = _normalize_search_query(q) 

8698 normalized_tags = _normalize_tags_query(tags) 

8699 tag_groups = _parse_tag_filter_groups(normalized_tags) 

8700 if not search_query and not tag_groups: 

8701 return _build_search_response(entity_key="servers", entity_type="servers", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

8702 

8703 team_ids = await _get_user_team_ids(user, db) 

8704 

8705 query = select(DbServer.id, DbServer.name, DbServer.description) 

8706 

8707 if not include_inactive: 

8708 query = query.where(DbServer.enabled.is_(True)) 

8709 

8710 # Build access conditions 

8711 # When team_id is specified, show ONLY items from that team (team-scoped view) 

8712 # Otherwise, show all accessible items (All Teams view) 

8713 if team_id: 

8714 # Team-specific view: only show servers from the specified team 

8715 if team_id in team_ids: 

8716 # Apply visibility check: team/public resources + user's own resources (including private) 

8717 team_access = [ 

8718 and_(DbServer.team_id == team_id, DbServer.visibility.in_(["team", "public"])), 

8719 and_(DbServer.team_id == team_id, DbServer.owner_email == user_email), 

8720 ] 

8721 query = query.where(or_(*team_access)) 

8722 LOGGER.debug(f"Filtering server search by team_id: {team_id}") 

8723 else: 

8724 # User is not a member of this team, return no results using SQLAlchemy's false() 

8725 LOGGER.warning(f"User {user_email} attempted to filter server search by team {team_id} but is not a member") 

8726 query = query.where(false()) 

8727 else: 

8728 # All Teams view: apply standard access conditions (owner, team, public) 

8729 access_conditions = [] 

8730 access_conditions.append(_owner_access_condition(DbServer.owner_email, DbServer.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8731 if team_ids: 

8732 access_conditions.append(and_(DbServer.team_id.in_(team_ids), DbServer.visibility.in_(["team", "public"]))) 

8733 access_conditions.append(DbServer.visibility == "public") 

8734 query = query.where(or_(*access_conditions)) 

8735 

8736 if search_query: 

8737 search_conditions = [ 

8738 _like_contains(func.lower(DbServer.id), search_query), 

8739 _like_contains(func.lower(DbServer.name), search_query), 

8740 _like_contains(func.lower(coalesce(DbServer.description, "")), search_query), 

8741 ] 

8742 query = query.where(or_(*search_conditions)) 

8743 

8744 query = _apply_tag_filter_groups(query, db, DbServer.tags, tag_groups) 

8745 

8746 if search_query: 

8747 query = query.order_by( 

8748 case( 

8749 (func.lower(DbServer.name).startswith(search_query), 1), 

8750 else_=2, 

8751 ), 

8752 func.lower(DbServer.name), 

8753 ) 

8754 else: 

8755 query = query.order_by(func.lower(DbServer.name)) 

8756 query = query.limit(limit) 

8757 

8758 results = db.execute(query).all() 

8759 servers = [] 

8760 for row in results: 

8761 servers.append( 

8762 { 

8763 "id": row.id, 

8764 "name": row.name, 

8765 "description": row.description, 

8766 } 

8767 ) 

8768 

8769 return _build_search_response(entity_key="servers", entity_type="servers", items=servers, query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

8770 

8771 

8772@admin_router.get("/resources/partial", response_class=HTMLResponse) 

8773@require_permission("resources.read", allow_admin_bypass=False) 

8774async def admin_resources_partial_html( 

8775 request: Request, 

8776 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

8777 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

8778 q: str = Query("", description="Search query"), 

8779 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

8780 include_inactive: bool = False, 

8781 render: Optional[str] = Query(None, description="Render mode: 'controls' for pagination controls only"), 

8782 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

8783 team_id: Optional[str] = Depends(_validated_team_id_param), 

8784 db: Session = Depends(get_db), 

8785 user=Depends(get_current_user_with_permissions), 

8786): 

8787 """Return HTML partial for paginated resources list (HTMX endpoint). 

8788 

8789 This endpoint mirrors the behavior of the tools and prompts partial 

8790 endpoints. It returns a template fragment suitable for HTMX-based 

8791 pagination/infinite-scroll within the admin UI. 

8792 

8793 Args: 

8794 request (Request): FastAPI request object used by the template engine. 

8795 page (int): Page number (1-indexed). 

8796 per_page (int): Number of items per page (bounded by settings). 

8797 q (str): Free-text query string. 

8798 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

8799 include_inactive (bool): If True, include inactive resources in results. 

8800 render (Optional[str]): Render mode; when set to "controls" returns only 

8801 pagination controls. Other supported value: "selector" for selector 

8802 items used by infinite scroll selectors. 

8803 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

8804 team_id (Optional[str]): Filter by team ID. 

8805 db (Session): Database session (dependency-injected). 

8806 user: Authenticated user object from dependency injection. 

8807 

8808 Returns: 

8809 Union[HTMLResponse, TemplateResponse]: Rendered template response with the 

8810 resources partial (rows + controls), pagination controls only, or selector 

8811 items depending on the ``render`` parameter. 

8812 """ 

8813 

8814 LOGGER.debug( 

8815 f"[RESOURCES FILTER DEBUG] User {get_user_email(user)} requested resources HTML partial (page={page}, per_page={per_page}, render={render}, gateway_id={gateway_id}, team_id={team_id})" 

8816 ) 

8817 search_query = _normalize_search_query(q) 

8818 normalized_tags = _normalize_tags_query(tags) 

8819 tag_groups = _parse_tag_filter_groups(normalized_tags) 

8820 

8821 # Normalize per_page 

8822 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) 

8823 

8824 user_email = get_user_email(user) 

8825 

8826 # Team scoping 

8827 team_ids = await _get_user_team_ids(user, db) 

8828 

8829 # Build base query 

8830 query = select(DbResource) 

8831 

8832 # Apply gateway filter if provided 

8833 if gateway_id: 

8834 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

8835 if gateway_ids: 

8836 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

8837 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

8838 if non_null_ids and null_requested: 

8839 query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None))) 

8840 LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL") 

8841 elif null_requested: 

8842 query = query.where(DbResource.gateway_id.is_(None)) 

8843 LOGGER.debug("[RESOURCES FILTER DEBUG] Filtering resources by NULL gateway_id (RestTool)") 

8844 else: 

8845 query = query.where(DbResource.gateway_id.in_(non_null_ids)) 

8846 LOGGER.debug(f"[RESOURCES FILTER DEBUG] Filtering resources by gateway IDs: {non_null_ids}") 

8847 else: 

8848 LOGGER.debug("[RESOURCES FILTER DEBUG] No gateway_id filter provided, showing all resources") 

8849 

8850 # Apply active/inactive filter 

8851 if not include_inactive: 

8852 query = query.where(DbResource.enabled.is_(True)) 

8853 

8854 # Build access conditions 

8855 # When team_id is specified, show ONLY items from that team (team-scoped view) 

8856 # Otherwise, show all accessible items (All Teams view) 

8857 if team_id: 

8858 # Team-specific view: only show resources from the specified team 

8859 if team_id in team_ids: 

8860 # Apply visibility check: team/public resources + user's own resources (including private) 

8861 team_access = [ 

8862 and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])), 

8863 and_(DbResource.team_id == team_id, DbResource.owner_email == user_email), 

8864 ] 

8865 query = query.where(or_(*team_access)) 

8866 LOGGER.debug(f"Filtering resources by team_id: {team_id}") 

8867 else: 

8868 # User is not a member of this team, return no results using SQLAlchemy's false() 

8869 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

8870 query = query.where(false()) 

8871 else: 

8872 # All Teams view: apply standard access conditions (owner, team, public) 

8873 access_conditions = [] 

8874 access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

8875 if team_ids: 

8876 access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"]))) 

8877 access_conditions.append(DbResource.visibility == "public") 

8878 query = query.where(or_(*access_conditions)) 

8879 

8880 if search_query: 

8881 query = query.where( 

8882 or_( 

8883 _like_contains(func.lower(DbResource.id), search_query), 

8884 _like_contains(func.lower(DbResource.name), search_query), 

8885 _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query), 

8886 _like_contains(func.lower(coalesce(DbResource.description, "")), search_query), 

8887 ) 

8888 ) 

8889 

8890 query = _apply_tag_filter_groups(query, db, DbResource.tags, tag_groups) 

8891 

8892 # Add sorting for consistent pagination 

8893 query = query.order_by(desc(DbResource.created_at), desc(DbResource.id)) 

8894 

8895 # Build query params for pagination links 

8896 query_params = {} 

8897 if include_inactive: 

8898 query_params["include_inactive"] = "true" 

8899 if gateway_id: 

8900 query_params["gateway_id"] = gateway_id 

8901 if team_id: 

8902 query_params["team_id"] = team_id 

8903 if search_query: 

8904 query_params["q"] = search_query 

8905 if normalized_tags: 

8906 query_params["tags"] = normalized_tags 

8907 

8908 # Use unified pagination function 

8909 root_path = request.scope.get("root_path", "") 

8910 base_url = f"{root_path}/admin/resources/partial" 

8911 paginated_result = await paginate_query( 

8912 db=db, 

8913 query=query, 

8914 page=page, 

8915 per_page=per_page, 

8916 cursor=None, # HTMX partials use page-based navigation 

8917 base_url=base_url, 

8918 query_params=query_params, 

8919 use_cursor_threshold=False, # Disable auto-cursor switching for UI 

8920 ) 

8921 

8922 # Extract paginated resources (DbResource objects) 

8923 resources_db = paginated_result["data"] 

8924 pagination = paginated_result["pagination"] 

8925 links = paginated_result["links"] 

8926 

8927 # Batch fetch team names for the resources to avoid N+1 queries 

8928 team_ids_set = {r.team_id for r in resources_db if r.team_id} 

8929 team_map = {} 

8930 if team_ids_set: 

8931 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all() 

8932 team_map = {team.id: team.name for team in teams} 

8933 

8934 # Apply team names to DB objects before conversion 

8935 for r in resources_db: 

8936 r.team = team_map.get(r.team_id) if r.team_id else None 

8937 

8938 # Batch convert to Pydantic models using resource service 

8939 resources_pydantic = [] 

8940 failed_count = 0 

8941 for r in resources_db: 

8942 try: 

8943 resources_pydantic.append(resource_service.convert_resource_to_read(r, include_metrics=False)) 

8944 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e: 

8945 failed_count += 1 

8946 LOGGER.exception(f"Failed to convert resource {getattr(r, 'id', 'unknown')} ({getattr(r, 'name', 'unknown')}): {e}") 

8947 _adjust_pagination_for_conversion_failures(pagination, failed_count) 

8948 

8949 data = jsonable_encoder(resources_pydantic) 

8950 

8951 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

8952 db.commit() 

8953 

8954 if render == "controls": 

8955 return request.app.state.templates.TemplateResponse( 

8956 request, 

8957 "pagination_controls.html", 

8958 { 

8959 "request": request, 

8960 "pagination": pagination.model_dump(), 

8961 "base_url": base_url, 

8962 "hx_target": "#resources-table-body", 

8963 "hx_indicator": "#resources-loading", 

8964 "query_params": query_params, 

8965 "root_path": request.scope.get("root_path", ""), 

8966 }, 

8967 ) 

8968 

8969 if render == "selector": 

8970 return request.app.state.templates.TemplateResponse( 

8971 request, 

8972 "resources_selector_items.html", 

8973 { 

8974 "request": request, 

8975 "data": data, 

8976 "pagination": pagination.model_dump(), 

8977 "root_path": request.scope.get("root_path", ""), 

8978 "gateway_id": gateway_id, 

8979 }, 

8980 ) 

8981 

8982 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

8983 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

8984 return request.app.state.templates.TemplateResponse( 

8985 request, 

8986 "resources_partial.html", 

8987 { 

8988 "request": request, 

8989 "data": data, 

8990 "pagination": pagination.model_dump(), 

8991 "links": links.model_dump() if links else None, 

8992 "root_path": request.scope.get("root_path", ""), 

8993 "include_inactive": include_inactive, 

8994 "query_params": query_params, 

8995 "current_user_email": user_email, 

8996 "is_admin": _is_admin, 

8997 "user_team_roles": _team_roles, 

8998 }, 

8999 ) 

9000 

9001 

9002@admin_router.get("/prompts/ids", response_class=JSONResponse) 

9003@require_permission("prompts.read", allow_admin_bypass=False) 

9004async def admin_get_all_prompt_ids( 

9005 include_inactive: bool = False, 

9006 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

9007 team_id: Optional[str] = Depends(_validated_team_id_param), 

9008 db: Session = Depends(get_db), 

9009 user=Depends(get_current_user_with_permissions), 

9010): 

9011 """Return all prompt IDs accessible to the current user (select-all helper). 

9012 

9013 This endpoint is used by UI "Select All" helpers to fetch only the IDs 

9014 of prompts the requesting user can access (owner, team, or public). 

9015 

9016 Args: 

9017 include_inactive (bool): When True include prompts that are inactive. 

9018 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local prompts). 

9019 team_id (Optional[str]): Filter by team ID. 

9020 db (Session): Database session (injected dependency). 

9021 user: Authenticated user object from dependency injection. 

9022 

9023 Returns: 

9024 dict: A dictionary containing two keys: 

9025 - "prompt_ids": List[str] of accessible prompt IDs. 

9026 - "count": int number of IDs returned. 

9027 """ 

9028 user_email = get_user_email(user) 

9029 team_ids = await _get_user_team_ids(user, db) 

9030 

9031 query = select(DbPrompt.id) 

9032 

9033 # Apply optional gateway/server scoping 

9034 if gateway_id: 

9035 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

9036 if gateway_ids: 

9037 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

9038 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

9039 if non_null_ids and null_requested: 

9040 query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None))) 

9041 LOGGER.debug(f"Filtering prompts by gateway IDs (including NULL): {non_null_ids} + NULL") 

9042 elif null_requested: 

9043 query = query.where(DbPrompt.gateway_id.is_(None)) 

9044 LOGGER.debug("Filtering prompts by NULL gateway_id (RestTool)") 

9045 else: 

9046 query = query.where(DbPrompt.gateway_id.in_(non_null_ids)) 

9047 LOGGER.debug(f"Filtering prompts by gateway IDs: {non_null_ids}") 

9048 

9049 if not include_inactive: 

9050 query = query.where(DbPrompt.enabled.is_(True)) 

9051 

9052 # Build access conditions 

9053 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9054 # Otherwise, show all accessible items (All Teams view) 

9055 if team_id: 

9056 # Team-specific view: only show prompts from the specified team 

9057 if team_id in team_ids: 

9058 # Apply visibility check: team/public resources + user's own resources (including private) 

9059 team_access = [ 

9060 and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])), 

9061 and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email), 

9062 ] 

9063 query = query.where(or_(*team_access)) 

9064 LOGGER.debug(f"Filtering prompt IDs by team_id: {team_id}") 

9065 else: 

9066 # User is not a member of this team, return no results using SQLAlchemy's false() 

9067 LOGGER.warning(f"User {user_email} attempted to filter prompt IDs by team {team_id} but is not a member") 

9068 query = query.where(false()) 

9069 else: 

9070 # All Teams view: apply standard access conditions (owner, team, public) 

9071 access_conditions = [] 

9072 access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9073 if team_ids: 

9074 access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"]))) 

9075 access_conditions.append(DbPrompt.visibility == "public") 

9076 query = query.where(or_(*access_conditions)) 

9077 

9078 prompt_ids = [row[0] for row in db.execute(query).all()] 

9079 return {"prompt_ids": prompt_ids, "count": len(prompt_ids)} 

9080 

9081 

9082@admin_router.get("/resources/ids", response_class=JSONResponse) 

9083@require_permission("resources.read", allow_admin_bypass=False) 

9084async def admin_get_all_resource_ids( 

9085 include_inactive: bool = False, 

9086 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

9087 team_id: Optional[str] = Depends(_validated_team_id_param), 

9088 db: Session = Depends(get_db), 

9089 user=Depends(get_current_user_with_permissions), 

9090): 

9091 """Return all resource IDs accessible to the current user (select-all helper). 

9092 

9093 This endpoint is used by UI "Select All" helpers to fetch only the IDs 

9094 of resources the requesting user can access (owner, team, or public). 

9095 

9096 Args: 

9097 include_inactive (bool): Whether to include inactive resources in the results. 

9098 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. Accepts the literal value 'null' to indicate NULL gateway_id (local resources). 

9099 team_id (Optional[str]): Filter by team ID. 

9100 db (Session): Database session dependency. 

9101 user: Authenticated user object from dependency injection. 

9102 

9103 Returns: 

9104 dict: A dictionary containing two keys: 

9105 - "resource_ids": List[str] of accessible resource IDs. 

9106 - "count": int number of IDs returned. 

9107 """ 

9108 user_email = get_user_email(user) 

9109 team_ids = await _get_user_team_ids(user, db) 

9110 

9111 query = select(DbResource.id) 

9112 

9113 # Apply optional gateway/server scoping 

9114 if gateway_id: 

9115 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

9116 if gateway_ids: 

9117 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

9118 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

9119 if non_null_ids and null_requested: 

9120 query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None))) 

9121 LOGGER.debug(f"Filtering resources by gateway IDs (including NULL): {non_null_ids} + NULL") 

9122 elif null_requested: 

9123 query = query.where(DbResource.gateway_id.is_(None)) 

9124 LOGGER.debug("Filtering resources by NULL gateway_id (RestTool)") 

9125 else: 

9126 query = query.where(DbResource.gateway_id.in_(non_null_ids)) 

9127 LOGGER.debug(f"Filtering resources by gateway IDs: {non_null_ids}") 

9128 

9129 if not include_inactive: 

9130 query = query.where(DbResource.enabled.is_(True)) 

9131 

9132 # Build access conditions 

9133 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9134 # Otherwise, show all accessible items (All Teams view) 

9135 if team_id: 

9136 # Team-specific view: only show resources from the specified team 

9137 if team_id in team_ids: 

9138 # Apply visibility check: team/public resources + user's own resources (including private) 

9139 team_access = [ 

9140 and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])), 

9141 and_(DbResource.team_id == team_id, DbResource.owner_email == user_email), 

9142 ] 

9143 query = query.where(or_(*team_access)) 

9144 LOGGER.debug(f"Filtering resource IDs by team_id: {team_id}") 

9145 else: 

9146 # User is not a member of this team, return no results using SQLAlchemy's false() 

9147 LOGGER.warning(f"User {user_email} attempted to filter resource IDs by team {team_id} but is not a member") 

9148 query = query.where(false()) 

9149 else: 

9150 # All Teams view: apply standard access conditions (owner, team, public) 

9151 access_conditions = [] 

9152 access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9153 if team_ids: 

9154 access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"]))) 

9155 access_conditions.append(DbResource.visibility == "public") 

9156 query = query.where(or_(*access_conditions)) 

9157 

9158 resource_ids = [row[0] for row in db.execute(query).all()] 

9159 return {"resource_ids": resource_ids, "count": len(resource_ids)} 

9160 

9161 

9162@admin_router.get("/resources/search", response_class=JSONResponse) 

9163@require_permission("resources.read", allow_admin_bypass=False) 

9164async def admin_search_resources( 

9165 q: str = Query("", description="Search query"), 

9166 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

9167 include_inactive: bool = False, 

9168 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size), 

9169 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

9170 team_id: Optional[str] = Depends(_validated_team_id_param), 

9171 db: Session = Depends(get_db), 

9172 user=Depends(get_current_user_with_permissions), 

9173): 

9174 """Search resources by name or description for selector search. 

9175 

9176 Performs a case-insensitive search over resource names and descriptions 

9177 and returns a limited list of matching resources suitable for selector 

9178 UIs (id, name, description). 

9179 

9180 Args: 

9181 q (str): Search query string. 

9182 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

9183 include_inactive (bool): When True include resources that are inactive. 

9184 limit (int): Maximum number of results to return (bounded by the query parameter). 

9185 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

9186 team_id (Optional[str]): Filter by team ID. 

9187 db (Session): Database session (injected dependency). 

9188 user: Authenticated user object from dependency injection. 

9189 

9190 Returns: 

9191 dict: A dictionary containing: 

9192 - "resources": List[dict] where each dict has keys "id", "name", "description". 

9193 - "count": int number of matched resources returned. 

9194 """ 

9195 user_email = get_user_email(user) 

9196 search_query = _normalize_search_query(q) 

9197 normalized_tags = _normalize_tags_query(tags) 

9198 tag_groups = _parse_tag_filter_groups(normalized_tags) 

9199 if not search_query and not tag_groups: 

9200 return _build_search_response(entity_key="resources", entity_type="resources", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

9201 

9202 team_ids = await _get_user_team_ids(user, db) 

9203 

9204 query = select(DbResource.id, DbResource.name, DbResource.description) 

9205 

9206 # Apply gateway filter if provided 

9207 if gateway_id: 

9208 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

9209 if gateway_ids: 

9210 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

9211 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

9212 if non_null_ids and null_requested: 

9213 query = query.where(or_(DbResource.gateway_id.in_(non_null_ids), DbResource.gateway_id.is_(None))) 

9214 LOGGER.debug(f"Filtering resource search by gateway IDs (including NULL): {non_null_ids} + NULL") 

9215 elif null_requested: 

9216 query = query.where(DbResource.gateway_id.is_(None)) 

9217 LOGGER.debug("Filtering resource search by NULL gateway_id") 

9218 else: 

9219 query = query.where(DbResource.gateway_id.in_(non_null_ids)) 

9220 LOGGER.debug(f"Filtering resource search by gateway IDs: {non_null_ids}") 

9221 

9222 if not include_inactive: 

9223 query = query.where(DbResource.enabled.is_(True)) 

9224 

9225 # Build access conditions 

9226 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9227 # Otherwise, show all accessible items (All Teams view) 

9228 if team_id: 

9229 # Team-specific view: only show resources from the specified team 

9230 if team_id in team_ids: 

9231 # Apply visibility check: team/public resources + user's own resources (including private) 

9232 team_access = [ 

9233 and_(DbResource.team_id == team_id, DbResource.visibility.in_(["team", "public"])), 

9234 and_(DbResource.team_id == team_id, DbResource.owner_email == user_email), 

9235 ] 

9236 query = query.where(or_(*team_access)) 

9237 LOGGER.debug(f"Filtering resource search by team_id: {team_id}") 

9238 else: 

9239 # User is not a member of this team, return no results using SQLAlchemy's false() 

9240 LOGGER.warning(f"User {user_email} attempted to filter resource search by team {team_id} but is not a member") 

9241 query = query.where(false()) 

9242 else: 

9243 # All Teams view: apply standard access conditions (owner, team, public) 

9244 access_conditions = [] 

9245 access_conditions.append(_owner_access_condition(DbResource.owner_email, DbResource.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9246 if team_ids: 

9247 access_conditions.append(and_(DbResource.team_id.in_(team_ids), DbResource.visibility.in_(["team", "public"]))) 

9248 access_conditions.append(DbResource.visibility == "public") 

9249 query = query.where(or_(*access_conditions)) 

9250 

9251 if search_query: 

9252 search_conditions = [ 

9253 _like_contains(func.lower(DbResource.id), search_query), 

9254 _like_contains(func.lower(DbResource.name), search_query), 

9255 _like_contains(func.lower(coalesce(DbResource.uri, "")), search_query), 

9256 _like_contains(func.lower(coalesce(DbResource.description, "")), search_query), 

9257 ] 

9258 query = query.where(or_(*search_conditions)) 

9259 

9260 query = _apply_tag_filter_groups(query, db, DbResource.tags, tag_groups) 

9261 

9262 if search_query: 

9263 query = query.order_by( 

9264 case( 

9265 (func.lower(DbResource.name).startswith(search_query), 1), 

9266 else_=2, 

9267 ), 

9268 func.lower(DbResource.name), 

9269 ) 

9270 else: 

9271 query = query.order_by(func.lower(DbResource.name)) 

9272 query = query.limit(limit) 

9273 

9274 results = db.execute(query).all() 

9275 resources = [] 

9276 for row in results: 

9277 resources.append({"id": row.id, "name": row.name, "description": row.description}) 

9278 

9279 return _build_search_response(entity_key="resources", entity_type="resources", items=resources, query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

9280 

9281 

9282@admin_router.get("/prompts/search", response_class=JSONResponse) 

9283@require_permission("prompts.read", allow_admin_bypass=False) 

9284async def admin_search_prompts( 

9285 q: str = Query("", description="Search query"), 

9286 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

9287 include_inactive: bool = False, 

9288 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size), 

9289 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

9290 team_id: Optional[str] = Depends(_validated_team_id_param), 

9291 db: Session = Depends(get_db), 

9292 user=Depends(get_current_user_with_permissions), 

9293): 

9294 """Search prompts by name or description for selector search. 

9295 

9296 Performs a case-insensitive search over prompt names and descriptions 

9297 and returns a limited list of matching prompts suitable for selector 

9298 UIs (id, name, description). 

9299 

9300 Args: 

9301 q (str): Search query string. 

9302 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

9303 include_inactive (bool): When True include prompts that are inactive. 

9304 limit (int): Maximum number of results to return (bounded by the query parameter). 

9305 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

9306 team_id (Optional[str]): Filter by team ID. 

9307 db (Session): Database session (injected dependency). 

9308 user: Authenticated user object from dependency injection. 

9309 

9310 Returns: 

9311 dict: A dictionary containing: 

9312 - "prompts": List[dict] where each dict has keys "id", "name", "description". 

9313 - "count": int number of matched prompts returned. 

9314 """ 

9315 user_email = get_user_email(user) 

9316 search_query = _normalize_search_query(q) 

9317 normalized_tags = _normalize_tags_query(tags) 

9318 tag_groups = _parse_tag_filter_groups(normalized_tags) 

9319 if not search_query and not tag_groups: 

9320 return _build_search_response(entity_key="prompts", entity_type="prompts", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

9321 

9322 team_ids = await _get_user_team_ids(user, db) 

9323 

9324 query = select(DbPrompt.id, DbPrompt.original_name, DbPrompt.display_name, DbPrompt.description) 

9325 

9326 # Apply gateway filter if provided 

9327 if gateway_id: 

9328 gateway_ids = [gid.strip() for gid in gateway_id.split(",") if gid.strip()] 

9329 if gateway_ids: 

9330 null_requested = any(gid.lower() == "null" for gid in gateway_ids) 

9331 non_null_ids = [gid for gid in gateway_ids if gid.lower() != "null"] 

9332 if non_null_ids and null_requested: 

9333 query = query.where(or_(DbPrompt.gateway_id.in_(non_null_ids), DbPrompt.gateway_id.is_(None))) 

9334 LOGGER.debug(f"Filtering prompt search by gateway IDs (including NULL): {non_null_ids} + NULL") 

9335 elif null_requested: 

9336 query = query.where(DbPrompt.gateway_id.is_(None)) 

9337 LOGGER.debug("Filtering prompt search by NULL gateway_id") 

9338 else: 

9339 query = query.where(DbPrompt.gateway_id.in_(non_null_ids)) 

9340 LOGGER.debug(f"Filtering prompt search by gateway IDs: {non_null_ids}") 

9341 

9342 if not include_inactive: 

9343 query = query.where(DbPrompt.enabled.is_(True)) 

9344 

9345 # Build access conditions 

9346 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9347 # Otherwise, show all accessible items (All Teams view) 

9348 if team_id: 

9349 # Team-specific view: only show prompts from the specified team 

9350 if team_id in team_ids: 

9351 # Apply visibility check: team/public resources + user's own resources (including private) 

9352 team_access = [ 

9353 and_(DbPrompt.team_id == team_id, DbPrompt.visibility.in_(["team", "public"])), 

9354 and_(DbPrompt.team_id == team_id, DbPrompt.owner_email == user_email), 

9355 ] 

9356 query = query.where(or_(*team_access)) 

9357 LOGGER.debug(f"Filtering prompt search by team_id: {team_id}") 

9358 else: 

9359 # User is not a member of this team, return no results using SQLAlchemy's false() 

9360 LOGGER.warning(f"User {user_email} attempted to filter prompt search by team {team_id} but is not a member") 

9361 query = query.where(false()) 

9362 else: 

9363 # All Teams view: apply standard access conditions (owner, team, public) 

9364 access_conditions = [] 

9365 access_conditions.append(_owner_access_condition(DbPrompt.owner_email, DbPrompt.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9366 if team_ids: 

9367 access_conditions.append(and_(DbPrompt.team_id.in_(team_ids), DbPrompt.visibility.in_(["team", "public"]))) 

9368 access_conditions.append(DbPrompt.visibility == "public") 

9369 query = query.where(or_(*access_conditions)) 

9370 

9371 if search_query: 

9372 search_conditions = [ 

9373 _like_contains(func.lower(DbPrompt.id), search_query), 

9374 _like_contains(func.lower(DbPrompt.original_name), search_query), 

9375 _like_contains(func.lower(coalesce(DbPrompt.display_name, "")), search_query), 

9376 _like_contains(func.lower(coalesce(DbPrompt.description, "")), search_query), 

9377 ] 

9378 query = query.where(or_(*search_conditions)) 

9379 

9380 query = _apply_tag_filter_groups(query, db, DbPrompt.tags, tag_groups) 

9381 

9382 if search_query: 

9383 query = query.order_by( 

9384 case( 

9385 (func.lower(DbPrompt.original_name).startswith(search_query), 1), 

9386 (func.lower(coalesce(DbPrompt.display_name, "")).startswith(search_query), 1), 

9387 else_=2, 

9388 ), 

9389 func.lower(DbPrompt.original_name), 

9390 ) 

9391 else: 

9392 query = query.order_by(func.lower(DbPrompt.original_name)) 

9393 query = query.limit(limit) 

9394 

9395 results = db.execute(query).all() 

9396 prompts = [] 

9397 for row in results: 

9398 prompts.append( 

9399 { 

9400 "id": row.id, 

9401 "name": row.original_name, 

9402 "original_name": row.original_name, 

9403 "display_name": row.display_name, 

9404 "description": row.description, 

9405 } 

9406 ) 

9407 

9408 return _build_search_response(entity_key="prompts", entity_type="prompts", items=prompts, query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

9409 

9410 

9411@admin_router.get("/tokens/partial", response_class=HTMLResponse) 

9412@require_permission("tokens.read", allow_admin_bypass=False) 

9413async def admin_tokens_partial_html( 

9414 request: Request, 

9415 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

9416 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

9417 include_inactive: bool = False, 

9418 render: Optional[str] = Query(None), 

9419 q: Optional[str] = Query(None, description="Search query for token name"), 

9420 team_id: Optional[str] = Depends(_validated_team_id_param), 

9421 db: Session = Depends(get_db), 

9422 user=Depends(get_current_user_with_permissions), 

9423): 

9424 """Return paginated tokens HTML partials for the admin UI. 

9425 

9426 This HTMX endpoint returns only the partial HTML used by the admin UI for 

9427 API tokens. It supports two render modes: 

9428 

9429 - default: full token cards + pagination controls 

9430 - ``render="controls"``: return only pagination controls 

9431 

9432 Args: 

9433 request: FastAPI request object used by the template engine. 

9434 page: Page number (1-indexed). 

9435 per_page: Number of items per page (bounded by settings). 

9436 include_inactive: If True, include inactive/expired tokens in results. 

9437 render: Render mode; one of None or "controls". 

9438 q: Search query string to filter tokens by name. 

9439 team_id: Filter by team ID. 

9440 db: Database session (dependency-injected). 

9441 user: Authenticated user object from dependency injection. 

9442 

9443 Returns: 

9444 HTMLResponse: A rendered template response containing either the token 

9445 cards partial or pagination controls depending on ``render``. 

9446 """ 

9447 user_email = get_user_email(user) 

9448 LOGGER.debug(f"User {user_email} requested tokens HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, q={q}, team_id={team_id})") 

9449 

9450 # Normalize per_page within configured bounds 

9451 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) 

9452 

9453 # Build base query: tokens owned by this user OR in user's teams 

9454 token_service = TokenCatalogService(db) 

9455 user_team_ids = await token_service.get_user_team_ids(user_email) 

9456 

9457 conditions = [EmailApiToken.user_email == user_email] 

9458 if user_team_ids: 

9459 conditions.append(EmailApiToken.team_id.in_(user_team_ids)) 

9460 

9461 query = select(EmailApiToken).where(or_(*conditions)) 

9462 

9463 if team_id: 

9464 query = query.where(EmailApiToken.team_id == team_id) 

9465 

9466 if not include_inactive: 

9467 query = query.where(and_(EmailApiToken.is_active.is_(True), or_(EmailApiToken.expires_at.is_(None), EmailApiToken.expires_at > utc_now()))) 

9468 

9469 # Apply search filter on name (case-insensitive) 

9470 if q and isinstance(q, str): 

9471 query = query.where(EmailApiToken.name.ilike(f"%{_escape_like(q.strip().lower())}%", escape="\\")) 

9472 

9473 query = query.order_by(desc(EmailApiToken.created_at)) 

9474 

9475 # Build query params for pagination links 

9476 query_params: Dict[str, Any] = {} 

9477 if include_inactive: 

9478 query_params["include_inactive"] = "true" 

9479 if team_id: 

9480 query_params["team_id"] = team_id 

9481 if q and isinstance(q, str): 

9482 query_params["q"] = q 

9483 

9484 # Use unified pagination function 

9485 paginated_result = await paginate_query( 

9486 db=db, 

9487 query=query, 

9488 page=page, 

9489 per_page=per_page, 

9490 cursor=None, 

9491 base_url=f"{settings.app_root_path}/admin/tokens/partial", 

9492 query_params=query_params, 

9493 use_cursor_threshold=False, 

9494 ) 

9495 

9496 tokens_db = paginated_result["data"] 

9497 pagination = paginated_result["pagination"] 

9498 links = paginated_result["links"] 

9499 

9500 base_url = f"{settings.app_root_path}/admin/tokens/partial" 

9501 

9502 if render == "controls": 

9503 db.commit() 

9504 return request.app.state.templates.TemplateResponse( 

9505 request, 

9506 "pagination_controls.html", 

9507 { 

9508 "request": request, 

9509 "pagination": pagination.model_dump(), 

9510 "base_url": base_url, 

9511 "hx_target": "#tokens-table", 

9512 "hx_indicator": "#tokens-loading", 

9513 "query_params": query_params, 

9514 "root_path": request.scope.get("root_path", ""), 

9515 }, 

9516 ) 

9517 

9518 # Build token data with revocation info and team names 

9519 

9520 # Batch fetch team names 

9521 team_ids_set = {t.team_id for t in tokens_db if t.team_id} 

9522 team_map: Dict[str, str] = {} 

9523 if team_ids_set: 

9524 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all() 

9525 team_map = {team.id: team.name for team in teams} 

9526 

9527 # Batch fetch revocation info (single query instead of N+1) 

9528 revocation_map = await token_service.get_token_revocations_batch([t.jti for t in tokens_db]) 

9529 

9530 # Build token data list 

9531 data = [] 

9532 for token in tokens_db: 

9533 revocation_info = revocation_map.get(token.jti) 

9534 data.append( 

9535 { 

9536 "id": token.id, 

9537 "name": token.name, 

9538 "description": token.description, 

9539 "user_email": token.user_email, 

9540 "team_id": token.team_id, 

9541 "team_name": team_map.get(token.team_id) if token.team_id else None, 

9542 "created_at": token.created_at, 

9543 "expires_at": token.expires_at, 

9544 "last_used": token.last_used, 

9545 "is_active": token.is_active, 

9546 "is_revoked": revocation_info is not None, 

9547 "revoked_at": revocation_info.revoked_at if revocation_info else None, 

9548 "revoked_by": revocation_info.revoked_by if revocation_info else None, 

9549 "revocation_reason": revocation_info.reason if revocation_info else None, 

9550 "tags": token.tags or [], 

9551 "server_id": token.server_id, 

9552 "resource_scopes": token.resource_scopes or [], 

9553 "ip_restrictions": token.ip_restrictions or [], 

9554 "time_restrictions": token.time_restrictions or {}, 

9555 "usage_limits": token.usage_limits or {}, 

9556 } 

9557 ) 

9558 data = jsonable_encoder(data) 

9559 for item in data: 

9560 item["_json"] = orjson.dumps(item).decode() 

9561 

9562 db.commit() 

9563 

9564 return request.app.state.templates.TemplateResponse( 

9565 request, 

9566 "tokens_partial.html", 

9567 { 

9568 "request": request, 

9569 "data": data, 

9570 "pagination": pagination.model_dump(), 

9571 "links": links.model_dump() if links else None, 

9572 "root_path": request.scope.get("root_path", ""), 

9573 "include_inactive": include_inactive, 

9574 "team_id": team_id, 

9575 }, 

9576 ) 

9577 

9578 

9579@admin_router.get("/tokens/search", response_class=JSONResponse) 

9580@require_permission("tokens.read", allow_admin_bypass=False) 

9581async def admin_search_tokens( 

9582 q: str = Query("", description="Search query"), 

9583 include_inactive: bool = False, 

9584 limit: int = Query(settings.pagination_default_page_size, ge=1, le=100, description="Max results"), 

9585 team_id: Optional[str] = Depends(_validated_team_id_param), 

9586 db: Session = Depends(get_db), 

9587 user=Depends(get_current_user_with_permissions), 

9588): 

9589 """Search API tokens by name. 

9590 

9591 Args: 

9592 q (str): Search query string to match against token names. 

9593 include_inactive (bool): Whether to include inactive/revoked tokens. 

9594 limit (int): Maximum number of results to return. 

9595 team_id (Optional[str]): Filter by team ID. 

9596 db (Session): Database session dependency. 

9597 user: Current authenticated user. 

9598 

9599 Returns: 

9600 JSONResponse: List of matching tokens with basic info. 

9601 """ 

9602 user_email = get_user_email(user) 

9603 LOGGER.debug(f"User {user_email} searching tokens with query='{q}', include_inactive={include_inactive}, limit={limit}, team_id={team_id}") 

9604 

9605 # Build base query: tokens owned by this user OR in user's teams 

9606 token_service = TokenCatalogService(db) 

9607 user_team_ids = await token_service.get_user_team_ids(user_email) 

9608 

9609 conditions = [EmailApiToken.user_email == user_email] 

9610 if user_team_ids: 

9611 conditions.append(EmailApiToken.team_id.in_(user_team_ids)) 

9612 

9613 query = select(EmailApiToken).where(or_(*conditions)) 

9614 

9615 if team_id: 

9616 query = query.where(EmailApiToken.team_id == team_id) 

9617 

9618 if not include_inactive: 

9619 query = query.where(and_(EmailApiToken.is_active.is_(True), or_(EmailApiToken.expires_at.is_(None), EmailApiToken.expires_at > utc_now()))) 

9620 

9621 # Apply search filter on name (case-insensitive) 

9622 if q and isinstance(q, str): 

9623 query = query.where(EmailApiToken.name.ilike(f"%{_escape_like(q.strip().lower())}%", escape="\\")) 

9624 

9625 query = query.order_by(desc(EmailApiToken.created_at)).limit(limit) 

9626 

9627 result = db.execute(query) 

9628 tokens = result.scalars().all() 

9629 

9630 # Batch fetch revocation info (single query instead of N+1) 

9631 revocation_map = await token_service.get_token_revocations_batch([t.jti for t in tokens]) 

9632 

9633 token_data = [] 

9634 for token in tokens: 

9635 revocation_info = revocation_map.get(token.jti) 

9636 token_data.append( 

9637 { 

9638 "id": token.id, 

9639 "name": token.name, 

9640 "description": token.description, 

9641 "user_email": token.user_email, 

9642 "team_id": token.team_id, 

9643 "created_at": token.created_at, 

9644 "expires_at": token.expires_at, 

9645 "last_used": token.last_used, 

9646 "is_active": token.is_active, 

9647 "is_revoked": revocation_info is not None, 

9648 "tags": token.tags or [], 

9649 "server_id": token.server_id, 

9650 } 

9651 ) 

9652 

9653 db.commit() 

9654 return token_data 

9655 

9656 

9657@admin_router.get("/a2a/partial", response_class=HTMLResponse) 

9658@require_permission("a2a.read", allow_admin_bypass=False) 

9659async def admin_a2a_partial_html( 

9660 request: Request, 

9661 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

9662 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

9663 q: str = Query("", description="Search query"), 

9664 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

9665 include_inactive: bool = False, 

9666 render: Optional[str] = Query(None), 

9667 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

9668 team_id: Optional[str] = Depends(_validated_team_id_param), 

9669 db: Session = Depends(get_db), 

9670 user=Depends(get_current_user_with_permissions), 

9671): 

9672 """Return paginated a2a agents HTML partials for the admin UI. 

9673 

9674 This HTMX endpoint returns only the partial HTML used by the admin UI for 

9675 a2a agents. It supports three render modes: 

9676 

9677 - default: full table partial (rows + controls) 

9678 - ``render="controls"``: return only pagination controls 

9679 - ``render="selector"``: return selector items for infinite scroll 

9680 

9681 Args: 

9682 request (Request): FastAPI request object used by the template engine. 

9683 page (int): Page number (1-indexed). 

9684 per_page (int): Number of items per page (bounded by settings). 

9685 q (str): Free-text query string. 

9686 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

9687 include_inactive (bool): If True, include inactive a2a agents in results. 

9688 render (Optional[str]): Render mode; one of None, "controls", "selector". 

9689 gateway_id (Optional[str]): Filter by gateway ID(s), comma-separated. 

9690 team_id (Optional[str]): Filter by team ID. 

9691 db (Session): Database session (dependency-injected). 

9692 user: Authenticated user object from dependency injection. 

9693 

9694 Returns: 

9695 Union[HTMLResponse, TemplateResponse]: A rendered template response 

9696 containing either the table partial, pagination controls, or selector 

9697 items depending on ``render``. The response contains JSON-serializable 

9698 encoded a2a agent data when templates expect it. 

9699 """ 

9700 LOGGER.debug( 

9701 f"User {get_user_email(user)} requested a2a_agents HTML partial (page={page}, per_page={per_page}, include_inactive={include_inactive}, render={render}, gateway_id={gateway_id}, team_id={team_id})" 

9702 ) 

9703 search_query = _normalize_search_query(q) 

9704 normalized_tags = _normalize_tags_query(tags) 

9705 tag_groups = _parse_tag_filter_groups(normalized_tags) 

9706 # Normalize per_page within configured bounds 

9707 per_page = max(settings.pagination_min_page_size, min(per_page, settings.pagination_max_page_size)) 

9708 

9709 user_email = get_user_email(user) 

9710 

9711 # Team scoping 

9712 team_ids = await _get_user_team_ids(user, db) 

9713 

9714 # Build base query 

9715 query = select(DbA2AAgent) 

9716 

9717 # Note: A2A agents don't have gateway_id field, they connect directly via endpoint_url 

9718 # The gateway_id parameter is ignored for A2A agents 

9719 

9720 if not include_inactive: 

9721 query = query.where(DbA2AAgent.enabled.is_(True)) 

9722 

9723 # Build access conditions 

9724 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9725 # Otherwise, show all accessible items (All Teams view) 

9726 if team_id: 

9727 # Team-specific view: only show a2a agents from the specified team 

9728 if team_id in team_ids: 

9729 # Apply visibility check: team/public resources + user's own resources (including private) 

9730 team_access = [ 

9731 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])), 

9732 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email), 

9733 ] 

9734 query = query.where(or_(*team_access)) 

9735 LOGGER.debug(f"Filtering a2a agents by team_id: {team_id}") 

9736 else: 

9737 # User is not a member of this team, return no results using SQLAlchemy's false() 

9738 LOGGER.warning(f"User {user_email} attempted to filter by team {team_id} but is not a member") 

9739 query = query.where(false()) 

9740 else: 

9741 # All Teams view: apply standard access conditions (owner, team, public) 

9742 access_conditions = [] 

9743 access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9744 if team_ids: 

9745 access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"]))) 

9746 access_conditions.append(DbA2AAgent.visibility == "public") 

9747 query = query.where(or_(*access_conditions)) 

9748 

9749 if search_query: 

9750 query = query.where( 

9751 or_( 

9752 _like_contains(func.lower(DbA2AAgent.id), search_query), 

9753 _like_contains(func.lower(DbA2AAgent.name), search_query), 

9754 _like_contains(func.lower(coalesce(DbA2AAgent.endpoint_url, "")), search_query), 

9755 _like_contains(func.lower(coalesce(DbA2AAgent.description, "")), search_query), 

9756 ) 

9757 ) 

9758 

9759 query = _apply_tag_filter_groups(query, db, DbA2AAgent.tags, tag_groups) 

9760 

9761 # Apply pagination ordering for cursor support 

9762 query = query.order_by(desc(DbA2AAgent.created_at), desc(DbA2AAgent.id)) 

9763 

9764 # Build query params for pagination links 

9765 query_params = {} 

9766 if include_inactive: 

9767 query_params["include_inactive"] = "true" 

9768 if gateway_id: 

9769 query_params["gateway_id"] = gateway_id 

9770 if team_id: 

9771 query_params["team_id"] = team_id 

9772 if search_query: 

9773 query_params["q"] = search_query 

9774 if normalized_tags: 

9775 query_params["tags"] = normalized_tags 

9776 

9777 # Use unified pagination function 

9778 root_path = request.scope.get("root_path", "") 

9779 base_url = f"{root_path}/admin/a2a/partial" 

9780 paginated_result = await paginate_query( 

9781 db=db, 

9782 query=query, 

9783 page=page, 

9784 per_page=per_page, 

9785 cursor=None, # HTMX partials use page-based navigation 

9786 base_url=base_url, 

9787 query_params=query_params, 

9788 use_cursor_threshold=False, # Disable auto-cursor switching for UI 

9789 ) 

9790 

9791 # Extract paginated a2a_agents (DbA2AAgent objects) 

9792 a2a_agents_db = paginated_result["data"] 

9793 pagination = paginated_result["pagination"] 

9794 links = paginated_result["links"] 

9795 

9796 # Batch fetch team names for the a2a_agents to avoid N+1 queries 

9797 team_ids_set = {p.team_id for p in a2a_agents_db if p.team_id} 

9798 team_map = {} 

9799 if team_ids_set: 

9800 teams = db.execute(select(EmailTeam.id, EmailTeam.name).where(EmailTeam.id.in_(team_ids_set), EmailTeam.is_active.is_(True))).all() 

9801 team_map = {team.id: team.name for team in teams} 

9802 

9803 # Apply team names to DB objects before conversion 

9804 for p in a2a_agents_db: 

9805 p.team = team_map.get(p.team_id) if p.team_id else None 

9806 

9807 # Batch convert to Pydantic models using a2a service 

9808 # This eliminates the N+1 query problem from calling get_a2a_details() in a loop 

9809 a2a_agents_pydantic = [] 

9810 failed_count = 0 

9811 for a in a2a_agents_db: 

9812 try: 

9813 a2a_agents_pydantic.append(a2a_service.convert_agent_to_read(a, include_metrics=False)) 

9814 except (ValidationError, ValueError, KeyError, TypeError, binascii.Error) as e: 

9815 failed_count += 1 

9816 LOGGER.exception(f"Failed to convert a2a agent {getattr(a, 'id', 'unknown')} ({getattr(a, 'name', 'unknown')}): {e}") 

9817 _adjust_pagination_for_conversion_failures(pagination, failed_count) 

9818 data = jsonable_encoder(a2a_agents_pydantic) 

9819 

9820 # End the read-only transaction before template rendering to avoid idle-in-transaction timeouts. 

9821 db.commit() 

9822 

9823 if render == "controls": 

9824 return request.app.state.templates.TemplateResponse( 

9825 request, 

9826 "pagination_controls.html", 

9827 { 

9828 "request": request, 

9829 "pagination": pagination.model_dump(), 

9830 "base_url": base_url, 

9831 "hx_target": "#agents-table-body", 

9832 "hx_indicator": "#agents-loading", 

9833 "query_params": query_params, 

9834 "root_path": request.scope.get("root_path", ""), 

9835 }, 

9836 ) 

9837 

9838 if render == "selector": 

9839 return request.app.state.templates.TemplateResponse( 

9840 request, 

9841 "agents_selector_items.html", 

9842 { 

9843 "request": request, 

9844 "data": data, 

9845 "pagination": pagination.model_dump(), 

9846 "root_path": request.scope.get("root_path", ""), 

9847 "gateway_id": gateway_id, 

9848 }, 

9849 ) 

9850 

9851 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

9852 _team_roles = _get_user_team_roles(db, user_email) if not _is_admin else {} 

9853 return request.app.state.templates.TemplateResponse( 

9854 request, 

9855 "agents_partial.html", 

9856 { 

9857 "request": request, 

9858 "data": data, 

9859 "pagination": pagination.model_dump(), 

9860 "links": links.model_dump() if links else None, 

9861 "root_path": request.scope.get("root_path", ""), 

9862 "include_inactive": include_inactive, 

9863 "query_params": query_params, 

9864 "current_user_email": user_email, 

9865 "is_admin": _is_admin, 

9866 "user_team_roles": _team_roles, 

9867 }, 

9868 ) 

9869 

9870 

9871@admin_router.get("/a2a/ids", response_class=JSONResponse) 

9872@require_permission("a2a.read", allow_admin_bypass=False) 

9873async def admin_get_all_agent_ids( 

9874 include_inactive: bool = False, 

9875 team_id: Optional[str] = Depends(_validated_team_id_param), 

9876 db: Session = Depends(get_db), 

9877 user=Depends(get_current_user_with_permissions), 

9878): 

9879 """Return all agent IDs accessible to the current user (select-all helper). 

9880 

9881 This endpoint is used by UI "Select All" helpers to fetch only the IDs 

9882 of a2a agents the requesting user can access (owner, team, or public). 

9883 

9884 Args: 

9885 include_inactive (bool): When True include a2a agents that are inactive. 

9886 team_id (Optional[str]): Filter by team ID. 

9887 db (Session): Database session (injected dependency). 

9888 user: Authenticated user object from dependency injection. 

9889 

9890 Returns: 

9891 dict: A dictionary containing two keys: 

9892 - "agent_ids": List[str] of accessible agent IDs. 

9893 - "count": int number of IDs returned. 

9894 """ 

9895 user_email = get_user_email(user) 

9896 team_ids = await _get_user_team_ids(user, db) 

9897 

9898 query = select(DbA2AAgent.id) 

9899 

9900 if not include_inactive: 

9901 query = query.where(DbA2AAgent.enabled.is_(True)) 

9902 

9903 # Build access conditions 

9904 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9905 # Otherwise, show all accessible items (All Teams view) 

9906 if team_id: 

9907 if team_id in team_ids: 

9908 # Apply visibility check: team/public resources + user's own resources (including private) 

9909 team_access = [ 

9910 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])), 

9911 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email), 

9912 ] 

9913 query = query.where(or_(*team_access)) 

9914 LOGGER.debug(f"Filtering A2A agent IDs by team_id: {team_id}") 

9915 else: 

9916 LOGGER.warning(f"User {user_email} attempted to filter A2A agent IDs by team {team_id} but is not a member") 

9917 query = query.where(false()) 

9918 else: 

9919 # All Teams view: apply standard access conditions (owner, team, public) 

9920 access_conditions = [] 

9921 access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9922 access_conditions.append(DbA2AAgent.visibility == "public") 

9923 if team_ids: 

9924 access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"]))) 

9925 query = query.where(or_(*access_conditions)) 

9926 

9927 agent_ids = [row[0] for row in db.execute(query).all()] 

9928 return {"agent_ids": agent_ids, "count": len(agent_ids)} 

9929 

9930 

9931@admin_router.get("/a2a/search", response_class=JSONResponse) 

9932@require_permission("a2a.read", allow_admin_bypass=False) 

9933async def admin_search_a2a_agents( 

9934 q: str = Query("", description="Search query"), 

9935 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

9936 include_inactive: bool = False, 

9937 limit: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size), 

9938 team_id: Optional[str] = Depends(_validated_team_id_param), 

9939 db: Session = Depends(get_db), 

9940 user=Depends(get_current_user_with_permissions), 

9941): 

9942 """Search a2a agents by name or description for selector search. 

9943 

9944 Performs a case-insensitive search over prompt names and descriptions 

9945 and returns a limited list of matching a2a agents suitable for selector 

9946 UIs (id, name, description). 

9947 

9948 Args: 

9949 q (str): Search query string. 

9950 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

9951 include_inactive (bool): When True include a2a agents that are inactive. 

9952 limit (int): Maximum number of results to return (bounded by the query parameter). 

9953 team_id (Optional[str]): Filter by team ID. 

9954 db (Session): Database session (injected dependency). 

9955 user: Authenticated user object from dependency injection. 

9956 

9957 Returns: 

9958 dict: A dictionary containing: 

9959 - "agents": List[dict] where each dict has keys "id", "name", "description". 

9960 - "count": int number of matched a2a agents returned. 

9961 """ 

9962 user_email = get_user_email(user) 

9963 search_query = _normalize_search_query(q) 

9964 normalized_tags = _normalize_tags_query(tags) 

9965 tag_groups = _parse_tag_filter_groups(normalized_tags) 

9966 if not search_query and not tag_groups: 

9967 return _build_search_response(entity_key="agents", entity_type="agents", items=[], query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

9968 

9969 team_ids = await _get_user_team_ids(user, db) 

9970 

9971 query = select(DbA2AAgent.id, DbA2AAgent.name, DbA2AAgent.endpoint_url, DbA2AAgent.description) 

9972 

9973 if not include_inactive: 

9974 query = query.where(DbA2AAgent.enabled.is_(True)) 

9975 

9976 # Build access conditions 

9977 # When team_id is specified, show ONLY items from that team (team-scoped view) 

9978 # Otherwise, show all accessible items (All Teams view) 

9979 if team_id: 

9980 if team_id in team_ids: 

9981 # Apply visibility check: team/public resources + user's own resources (including private) 

9982 team_access = [ 

9983 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.visibility.in_(["team", "public"])), 

9984 and_(DbA2AAgent.team_id == team_id, DbA2AAgent.owner_email == user_email), 

9985 ] 

9986 query = query.where(or_(*team_access)) 

9987 LOGGER.debug(f"Filtering A2A agent search by team_id: {team_id}") 

9988 else: 

9989 LOGGER.warning(f"User {user_email} attempted to filter A2A agent search by team {team_id} but is not a member") 

9990 query = query.where(false()) 

9991 else: 

9992 # All Teams view: apply standard access conditions (owner, team, public) 

9993 access_conditions = [] 

9994 access_conditions.append(_owner_access_condition(DbA2AAgent.owner_email, DbA2AAgent.team_id, user_email=user_email, team_ids=team_ids, user=user)) 

9995 access_conditions.append(DbA2AAgent.visibility == "public") 

9996 if team_ids: 

9997 access_conditions.append(and_(DbA2AAgent.team_id.in_(team_ids), DbA2AAgent.visibility.in_(["team", "public"]))) 

9998 query = query.where(or_(*access_conditions)) 

9999 

10000 if search_query: 

10001 search_conditions = [ 

10002 _like_contains(func.lower(DbA2AAgent.id), search_query), 

10003 _like_contains(func.lower(DbA2AAgent.name), search_query), 

10004 _like_contains(func.lower(coalesce(DbA2AAgent.endpoint_url, "")), search_query), 

10005 _like_contains(func.lower(coalesce(DbA2AAgent.description, "")), search_query), 

10006 ] 

10007 query = query.where(or_(*search_conditions)) 

10008 

10009 query = _apply_tag_filter_groups(query, db, DbA2AAgent.tags, tag_groups) 

10010 

10011 if search_query: 

10012 query = query.order_by( 

10013 case( 

10014 (func.lower(DbA2AAgent.name).startswith(search_query), 1), 

10015 (func.lower(coalesce(DbA2AAgent.endpoint_url, "")).startswith(search_query), 1), 

10016 else_=2, 

10017 ), 

10018 func.lower(DbA2AAgent.name), 

10019 ) 

10020 else: 

10021 query = query.order_by(func.lower(DbA2AAgent.name)) 

10022 query = query.limit(limit) 

10023 

10024 results = db.execute(query).all() 

10025 agents = [] 

10026 for row in results: 

10027 agents.append( 

10028 { 

10029 "id": row.id, 

10030 "name": row.name, 

10031 "endpoint_url": row.endpoint_url, 

10032 "description": row.description, 

10033 } 

10034 ) 

10035 

10036 return _build_search_response(entity_key="agents", entity_type="agents", items=agents, query=search_query, tags=normalized_tags, tag_groups=tag_groups) 

10037 

10038 

10039@admin_router.get("/search", response_class=JSONResponse) 

10040@require_permission("admin.dashboard", allow_admin_bypass=False) 

10041async def admin_unified_search( 

10042 q: str = Query("", description="Search query"), 

10043 tags: Optional[str] = Query(None, description="Tag filter expression (comma=OR, plus=AND)"), 

10044 entity_types: Optional[str] = Query( 

10045 None, 

10046 description="Comma-separated entity types to include (servers,gateways,tools,resources,prompts,agents,teams,users)", 

10047 ), 

10048 include_inactive: bool = False, 

10049 limit: int = Query(8, ge=1, le=settings.pagination_max_page_size, description="Per-entity result limit"), 

10050 limit_per_type: Optional[int] = Query( 

10051 None, 

10052 ge=1, 

10053 le=settings.pagination_max_page_size, 

10054 description="Optional alias for per-entity result limit", 

10055 ), 

10056 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID(s), comma-separated"), 

10057 team_id: Optional[str] = Depends(_validated_team_id_param), 

10058 db: Session = Depends(get_db), 

10059 user=Depends(get_current_user_with_permissions), 

10060): 

10061 """Unified search across primary admin entities. 

10062 

10063 Args: 

10064 q (str): Free-text search query. 

10065 tags (Optional[str]): Tag filter expression (comma=OR, plus=AND). 

10066 entity_types (Optional[str]): Optional comma-separated entity type list. 

10067 include_inactive (bool): Whether to include inactive entities. 

10068 limit (int): Default per-entity limit for returned items. 

10069 limit_per_type (Optional[int]): Optional alias overriding ``limit``. 

10070 gateway_id (Optional[str]): Gateway filter for tools/resources/prompts. 

10071 team_id (Optional[str]): Team scope filter. 

10072 db (Session): Database session. 

10073 user: Authenticated user context. 

10074 

10075 Returns: 

10076 dict[str, Any]: Grouped and flattened search results with metadata. 

10077 

10078 Raises: 

10079 HTTPException: If ``entity_types`` is provided but contains no supported values. 

10080 """ 

10081 search_query = _normalize_search_query(q) 

10082 normalized_tags = _normalize_tags_query(tags) 

10083 normalized_entity_types = _normalize_tags_query(entity_types) 

10084 tag_groups = _parse_tag_filter_groups(normalized_tags) 

10085 

10086 supported_entity_types = ["servers", "gateways", "tools", "resources", "prompts", "agents", "teams", "users"] 

10087 default_entity_types = ["servers", "gateways", "tools", "resources", "prompts", "agents", "teams"] 

10088 selected_entity_types: list[str] = [] 

10089 if normalized_entity_types: 

10090 for raw_entity_type in normalized_entity_types.split(","): 

10091 candidate = raw_entity_type.strip().lower() 

10092 if not candidate: 

10093 continue 

10094 if candidate == "a2a": 

10095 candidate = "agents" 

10096 if candidate in supported_entity_types and candidate not in selected_entity_types: 

10097 selected_entity_types.append(candidate) 

10098 else: 

10099 selected_entity_types = default_entity_types.copy() 

10100 

10101 users_explicitly_requested = bool(normalized_entity_types and "users" in selected_entity_types) 

10102 if "users" in selected_entity_types: 

10103 can_search_users = await _has_permission(db=db, user=user, permission="admin.user_management") 

10104 if not can_search_users: 

10105 selected_entity_types = [entity_type for entity_type in selected_entity_types if entity_type != "users"] 

10106 if users_explicitly_requested and not selected_entity_types: 

10107 raise HTTPException(status_code=403, detail="Insufficient permissions. Required: admin.user_management") 

10108 

10109 if not selected_entity_types: 

10110 raise HTTPException(status_code=400, detail="No valid entity_types requested") 

10111 

10112 resolved_limit = _normalize_int_query(limit, 8) 

10113 effective_limit = _normalize_int_query(limit_per_type, resolved_limit) 

10114 effective_limit = max(1, min(effective_limit, settings.pagination_max_page_size)) 

10115 

10116 if not search_query and not tag_groups: 

10117 return { 

10118 "query": search_query, 

10119 "tags": normalized_tags, 

10120 "entity_types": selected_entity_types, 

10121 "limit_per_type": effective_limit, 

10122 "filters_applied": {"q": search_query, "tags": normalized_tags, "tag_groups": tag_groups}, 

10123 "results": {key: [] for key in selected_entity_types}, 

10124 "groups": [], 

10125 "items": [], 

10126 "count": 0, 

10127 } 

10128 

10129 async def _safe_entity_search(search_callable, empty_key: str, **kwargs: Any) -> dict[str, Any]: 

10130 try: 

10131 return await search_callable(**kwargs) 

10132 except HTTPException as exc: 

10133 if exc.status_code in {401, 403}: 

10134 return {empty_key: [], "items": [], "count": 0} 

10135 raise 

10136 

10137 # Pre-fetch team IDs once and inject into the user context so that 

10138 # individual search functions reuse them via _get_user_team_ids(). 

10139 _team_ids = await _get_user_team_ids(user, db) 

10140 user = dict(user) # shallow copy to avoid mutating the caller's dict 

10141 user["_cached_team_ids"] = _team_ids 

10142 

10143 grouped_results: dict[str, list[dict[str, Any]]] = {entity_type: [] for entity_type in selected_entity_types} 

10144 

10145 if "servers" in selected_entity_types: 

10146 servers_result = await _safe_entity_search( 

10147 admin_search_servers, 

10148 "servers", 

10149 q=search_query, 

10150 tags=normalized_tags, 

10151 include_inactive=include_inactive, 

10152 limit=effective_limit, 

10153 team_id=team_id, 

10154 db=db, 

10155 user=user, 

10156 ) 

10157 grouped_results["servers"] = typing_cast(list[dict[str, Any]], servers_result.get("servers", servers_result.get("items", []))) 

10158 

10159 if "gateways" in selected_entity_types: 

10160 gateways_result = await _safe_entity_search( 

10161 admin_search_gateways, 

10162 "gateways", 

10163 q=search_query, 

10164 tags=normalized_tags, 

10165 include_inactive=include_inactive, 

10166 limit=effective_limit, 

10167 team_id=team_id, 

10168 db=db, 

10169 user=user, 

10170 ) 

10171 grouped_results["gateways"] = typing_cast(list[dict[str, Any]], gateways_result.get("gateways", gateways_result.get("items", []))) 

10172 

10173 if "tools" in selected_entity_types: 

10174 tools_result = await _safe_entity_search( 

10175 admin_search_tools, 

10176 "tools", 

10177 q=search_query, 

10178 tags=normalized_tags, 

10179 include_inactive=include_inactive, 

10180 limit=effective_limit, 

10181 gateway_id=gateway_id, 

10182 team_id=team_id, 

10183 db=db, 

10184 user=user, 

10185 ) 

10186 grouped_results["tools"] = typing_cast(list[dict[str, Any]], tools_result.get("tools", tools_result.get("items", []))) 

10187 

10188 if "resources" in selected_entity_types: 

10189 resources_result = await _safe_entity_search( 

10190 admin_search_resources, 

10191 "resources", 

10192 q=search_query, 

10193 tags=normalized_tags, 

10194 include_inactive=include_inactive, 

10195 limit=effective_limit, 

10196 gateway_id=gateway_id, 

10197 team_id=team_id, 

10198 db=db, 

10199 user=user, 

10200 ) 

10201 grouped_results["resources"] = typing_cast(list[dict[str, Any]], resources_result.get("resources", resources_result.get("items", []))) 

10202 

10203 if "prompts" in selected_entity_types: 

10204 prompts_result = await _safe_entity_search( 

10205 admin_search_prompts, 

10206 "prompts", 

10207 q=search_query, 

10208 tags=normalized_tags, 

10209 include_inactive=include_inactive, 

10210 limit=effective_limit, 

10211 gateway_id=gateway_id, 

10212 team_id=team_id, 

10213 db=db, 

10214 user=user, 

10215 ) 

10216 grouped_results["prompts"] = typing_cast(list[dict[str, Any]], prompts_result.get("prompts", prompts_result.get("items", []))) 

10217 

10218 if "agents" in selected_entity_types: 

10219 agents_result = await _safe_entity_search( 

10220 admin_search_a2a_agents, 

10221 "agents", 

10222 q=search_query, 

10223 tags=normalized_tags, 

10224 include_inactive=include_inactive, 

10225 limit=effective_limit, 

10226 team_id=team_id, 

10227 db=db, 

10228 user=user, 

10229 ) 

10230 grouped_results["agents"] = typing_cast(list[dict[str, Any]], agents_result.get("agents", agents_result.get("items", []))) 

10231 

10232 # Teams and users do not support tag filtering; only include when a text query exists. 

10233 if "teams" in selected_entity_types and search_query: 

10234 teams_result = await _safe_entity_search( 

10235 admin_search_teams, 

10236 "teams", 

10237 q=search_query, 

10238 include_inactive=include_inactive, 

10239 limit=effective_limit, 

10240 visibility=None, 

10241 db=db, 

10242 user=user, 

10243 ) 

10244 if isinstance(teams_result, list): 

10245 grouped_results["teams"] = typing_cast(list[dict[str, Any]], teams_result) 

10246 else: 

10247 grouped_results["teams"] = typing_cast(list[dict[str, Any]], teams_result.get("teams", teams_result.get("items", []))) 

10248 

10249 if "users" in selected_entity_types and search_query: 

10250 users_result = await _safe_entity_search( 

10251 admin_search_users, 

10252 "users", 

10253 q=search_query, 

10254 limit=effective_limit, 

10255 db=db, 

10256 user=user, 

10257 ) 

10258 grouped_results["users"] = typing_cast(list[dict[str, Any]], users_result.get("users", users_result.get("items", []))) 

10259 

10260 groups = [] 

10261 flat_items: list[dict[str, Any]] = [] 

10262 for entity_type in selected_entity_types: 

10263 items = grouped_results.get(entity_type, []) 

10264 groups.append({"entity_type": entity_type, "count": len(items), "items": items}) 

10265 for item in items: 

10266 enriched_item = dict(item) 

10267 enriched_item["entity_type"] = entity_type 

10268 flat_items.append(enriched_item) 

10269 

10270 return { 

10271 "query": search_query, 

10272 "tags": normalized_tags, 

10273 "entity_types": selected_entity_types, 

10274 "limit_per_type": effective_limit, 

10275 "filters_applied": {"q": search_query, "tags": normalized_tags, "tag_groups": tag_groups}, 

10276 "results": grouped_results, 

10277 "groups": groups, 

10278 "items": flat_items, 

10279 "count": len(flat_items), 

10280 } 

10281 

10282 

10283@admin_router.get("/tools/{tool_id}", response_model=ToolRead) 

10284@require_permission("tools.read", allow_admin_bypass=False) 

10285async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

10286 """ 

10287 Retrieve specific tool details for the admin UI. 

10288 

10289 This endpoint fetches the details of a specific tool from the database 

10290 by its ID. It provides access to all information about the tool for 

10291 viewing and management purposes. 

10292 

10293 Args: 

10294 tool_id (str): The ID of the tool to retrieve. 

10295 db (Session): Database session dependency. 

10296 user (str): Authenticated user dependency. 

10297 

10298 Returns: 

10299 ToolRead: The tool details formatted with by_alias=True. 

10300 

10301 Raises: 

10302 HTTPException: If the tool is not found. 

10303 Exception: For any other unexpected errors. 

10304 

10305 Examples: 

10306 >>> callable(admin_get_tool) 

10307 True 

10308 >>> admin_get_tool.__name__ 

10309 'admin_get_tool' 

10310 """ 

10311 LOGGER.debug(f"User {get_user_email(user)} requested details for tool ID {tool_id}") 

10312 _user_email = get_user_email(user) 

10313 _is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False)) 

10314 _team_roles = _get_user_team_roles(db, _user_email) if not _is_admin else {} 

10315 try: 

10316 tool = await tool_service.get_tool(db, tool_id, requesting_user_email=_user_email, requesting_user_is_admin=_is_admin, requesting_user_team_roles=_team_roles) 

10317 return tool.model_dump(by_alias=True) 

10318 except ToolNotFoundError as e: 

10319 raise HTTPException(status_code=404, detail=str(e)) 

10320 except Exception as e: 

10321 # Catch any other unexpected errors and re-raise or log as needed 

10322 LOGGER.error(f"Error getting tool {tool_id}: {e}") 

10323 raise e # Re-raise for now, or return a 500 JSONResponse if preferred for API consistency 

10324 

10325 

10326@admin_router.post("/tools/") 

10327@admin_router.post("/tools") 

10328@require_permission("tools.create", allow_admin_bypass=False) 

10329async def admin_add_tool( 

10330 request: Request, 

10331 db: Session = Depends(get_db), 

10332 user=Depends(get_current_user_with_permissions), 

10333) -> JSONResponse: 

10334 """ 

10335 Add a tool via the admin UI with error handling. 

10336 

10337 Expects form fields: 

10338 - name 

10339 - url 

10340 - description (optional) 

10341 - requestType (mapped to request_type; defaults to "SSE") 

10342 - integrationType (mapped to integration_type; defaults to "MCP") 

10343 - headers (JSON string) 

10344 - input_schema (JSON string) 

10345 - output_schema (JSON string, optional) 

10346 - jsonpath_filter (optional) 

10347 - auth_type (optional) 

10348 - auth_username (optional) 

10349 - auth_password (optional) 

10350 - auth_token (optional) 

10351 - auth_header_key (optional) 

10352 - auth_header_value (optional) 

10353 

10354 Logs the raw form data and assembled tool_data for debugging. 

10355 

10356 Args: 

10357 request (Request): the FastAPI request object containing the form data. 

10358 db (Session): the SQLAlchemy database session. 

10359 user (str): identifier of the authenticated user. 

10360 

10361 Returns: 

10362 JSONResponse: a JSON response with `{"message": ..., "success": ...}` and an appropriate HTTP status code. 

10363 

10364 Examples: 

10365 >>> callable(admin_add_tool) 

10366 True 

10367 >>> admin_add_tool.__name__ 

10368 'admin_add_tool' 

10369 """ 

10370 LOGGER.debug(f"User {get_user_email(user)} is adding a new tool") 

10371 form = await request.form() 

10372 LOGGER.debug(f"Received form data: {dict(form)}") 

10373 integration_type = form.get("integrationType", "REST") 

10374 request_type = form.get("requestType") 

10375 visibility = str(form.get("visibility", "private")) 

10376 

10377 if request_type is None: 

10378 if integration_type == "REST": 

10379 request_type = "GET" # or any valid REST method default 

10380 elif integration_type == "MCP": 

10381 request_type = "SSE" 

10382 else: 

10383 request_type = "GET" 

10384 

10385 user_email = get_user_email(user) 

10386 # Determine personal team for default assignment 

10387 team_id = form.get("team_id", None) 

10388 team_service = TeamManagementService(db) 

10389 team_id = await team_service.verify_team_for_user(user_email, team_id) 

10390 # Parse tags from comma-separated string 

10391 tags_str = str(form.get("tags", "")) 

10392 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

10393 # Safely parse potential JSON strings from form 

10394 headers_raw = form.get("headers") 

10395 input_schema_raw = form.get("input_schema") 

10396 output_schema_raw = form.get("output_schema") 

10397 annotations_raw = form.get("annotations") 

10398 tool_data: dict[str, Any] = { 

10399 "name": form.get("name"), 

10400 "displayName": form.get("displayName"), 

10401 "url": form.get("url"), 

10402 "description": form.get("description"), 

10403 "request_type": request_type, 

10404 "integration_type": integration_type, 

10405 "headers": orjson.loads(headers_raw if isinstance(headers_raw, str) and headers_raw else "{}"), 

10406 "input_schema": orjson.loads(input_schema_raw if isinstance(input_schema_raw, str) and input_schema_raw else "{}"), 

10407 "output_schema": (orjson.loads(output_schema_raw) if isinstance(output_schema_raw, str) and output_schema_raw else None), 

10408 "annotations": orjson.loads(annotations_raw if isinstance(annotations_raw, str) and annotations_raw else "{}"), 

10409 "jsonpath_filter": form.get("jsonpath_filter", ""), 

10410 "auth_type": form.get("auth_type", ""), 

10411 "auth_username": form.get("auth_username", ""), 

10412 "auth_password": form.get("auth_password", ""), 

10413 "auth_token": form.get("auth_token", ""), 

10414 "auth_header_key": form.get("auth_header_key", ""), 

10415 "auth_header_value": form.get("auth_header_value", ""), 

10416 "tags": tags, 

10417 "visibility": visibility, 

10418 "team_id": team_id, 

10419 "owner_email": user_email, 

10420 "query_mapping": orjson.loads(form.get("query_mapping") or "{}"), 

10421 "header_mapping": orjson.loads(form.get("header_mapping") or "{}"), 

10422 "timeout_ms": int(form.get("timeout_ms")) if form.get("timeout_ms") and form.get("timeout_ms").strip() else None, 

10423 "expose_passthrough": form.get("expose_passthrough", "true"), 

10424 "allowlist": orjson.loads(form.get("allowlist") or "[]"), 

10425 "plugin_chain_pre": orjson.loads(form.get("plugin_chain_pre") or "[]"), 

10426 "plugin_chain_post": orjson.loads(form.get("plugin_chain_post") or "[]"), 

10427 } 

10428 LOGGER.debug(f"Tool data built: {tool_data}") 

10429 try: 

10430 tool = ToolCreate(**tool_data) 

10431 LOGGER.debug(f"Validated tool data: {tool.model_dump(by_alias=True)}") 

10432 

10433 # Extract creation metadata 

10434 metadata = MetadataCapture.extract_creation_metadata(request, user) 

10435 

10436 await tool_service.register_tool( 

10437 db, 

10438 tool, 

10439 created_by=metadata["created_by"], 

10440 created_from_ip=metadata["created_from_ip"], 

10441 created_via=metadata["created_via"], 

10442 created_user_agent=metadata["created_user_agent"], 

10443 import_batch_id=metadata["import_batch_id"], 

10444 federation_source=metadata["federation_source"], 

10445 ) 

10446 return ORJSONResponse( 

10447 content={"message": "Tool registered successfully!", "success": True}, 

10448 status_code=200, 

10449 ) 

10450 except IntegrityError as ex: 

10451 error_message = ErrorFormatter.format_database_error(ex) 

10452 LOGGER.error(f"IntegrityError in admin_add_tool: {error_message}") 

10453 return ORJSONResponse(status_code=409, content=error_message) 

10454 except ToolNameConflictError as ex: 

10455 LOGGER.error(f"ToolNameConflictError in admin_add_tool: {str(ex)}") 

10456 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

10457 except ToolError as ex: 

10458 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

10459 except ValidationError as ex: # This block should catch ValidationError 

10460 LOGGER.error(f"ValidationError in admin_add_tool: {str(ex)}") 

10461 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

10462 except Exception as ex: 

10463 LOGGER.error(f"Unexpected error in admin_add_tool: {str(ex)}") 

10464 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

10465 

10466 

10467@admin_router.post("/tools/{tool_id}/edit/", response_model=None) 

10468@admin_router.post("/tools/{tool_id}/edit", response_model=None) 

10469@require_permission("tools.update", allow_admin_bypass=False) 

10470async def admin_edit_tool( 

10471 tool_id: str, 

10472 request: Request, 

10473 db: Session = Depends(get_db), 

10474 user=Depends(get_current_user_with_permissions), 

10475) -> Response: 

10476 """ 

10477 Edit a tool via the admin UI. 

10478 

10479 Expects form fields: 

10480 - name 

10481 - displayName (optional) 

10482 - url 

10483 - description (optional) 

10484 - requestType (to be mapped to request_type) 

10485 - integrationType (to be mapped to integration_type) 

10486 - headers (as a JSON string) 

10487 - input_schema (as a JSON string) 

10488 - output_schema (as a JSON string, optional) 

10489 - jsonpathFilter (optional) 

10490 - auth_type (optional, string: "basic", "bearer", or empty) 

10491 - auth_username (optional, for basic auth) 

10492 - auth_password (optional, for basic auth) 

10493 - auth_token (optional, for bearer auth) 

10494 - auth_header_key (optional, for headers auth) 

10495 - auth_header_value (optional, for headers auth) 

10496 

10497 Assembles the tool_data dictionary by remapping form keys into the 

10498 snake-case keys expected by the schemas. 

10499 

10500 Args: 

10501 tool_id (str): The ID of the tool to edit. 

10502 request (Request): FastAPI request containing form data. 

10503 db (Session): Database session dependency. 

10504 user (str): Authenticated user dependency. 

10505 

10506 Returns: 

10507 Response: A redirect response to the tools section of the admin 

10508 dashboard with a status code of 303 (See Other), or a JSON response with 

10509 an error message if the update fails. 

10510 

10511 Examples: 

10512 >>> callable(admin_edit_tool) 

10513 True 

10514 >>> admin_edit_tool.__name__ 

10515 'admin_edit_tool' 

10516 """ 

10517 LOGGER.debug(f"User {get_user_email(user)} is editing tool ID {tool_id}") 

10518 form = await request.form() 

10519 # Parse tags from comma-separated string 

10520 tags_str = str(form.get("tags", "")) 

10521 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

10522 visibility = str(form.get("visibility", "private")) 

10523 

10524 user_email = get_user_email(user) 

10525 # Determine personal team for default assignment 

10526 team_id = form.get("team_id", None) 

10527 LOGGER.info(f"before Verifying team for user {user_email} with team_id {team_id}") 

10528 team_service = TeamManagementService(db) 

10529 team_id = await team_service.verify_team_for_user(user_email, team_id) 

10530 

10531 headers_raw2 = form.get("headers") 

10532 input_schema_raw2 = form.get("input_schema") 

10533 output_schema_raw2 = form.get("output_schema") 

10534 annotations_raw2 = form.get("annotations") 

10535 

10536 tool_data: dict[str, Any] = { 

10537 "name": form.get("name"), 

10538 "displayName": form.get("displayName"), 

10539 "custom_name": form.get("customName"), 

10540 "url": form.get("url"), 

10541 "description": form.get("description"), 

10542 "headers": orjson.loads(headers_raw2 if isinstance(headers_raw2, str) and headers_raw2 else "{}"), 

10543 "input_schema": orjson.loads(input_schema_raw2 if isinstance(input_schema_raw2, str) and input_schema_raw2 else "{}"), 

10544 "output_schema": (orjson.loads(output_schema_raw2) if isinstance(output_schema_raw2, str) and output_schema_raw2 else None), 

10545 "annotations": orjson.loads(annotations_raw2 if isinstance(annotations_raw2, str) and annotations_raw2 else "{}"), 

10546 "jsonpath_filter": form.get("jsonpathFilter", ""), 

10547 "auth_type": form.get("auth_type", ""), 

10548 "auth_username": form.get("auth_username", ""), 

10549 "auth_password": form.get("auth_password", ""), 

10550 "auth_token": form.get("auth_token", ""), 

10551 "auth_header_key": form.get("auth_header_key", ""), 

10552 "auth_header_value": form.get("auth_header_value", ""), 

10553 "tags": tags, 

10554 "visibility": visibility, 

10555 "owner_email": user_email, 

10556 "team_id": team_id, 

10557 } 

10558 # Only include integration_type if it's provided (not disabled in form) 

10559 if "integrationType" in form: 

10560 tool_data["integration_type"] = form.get("integrationType") 

10561 # Only include request_type if it's provided (not disabled in form) 

10562 if "requestType" in form: 

10563 tool_data["request_type"] = form.get("requestType") 

10564 LOGGER.debug(f"Tool update data built: {tool_data}") 

10565 try: 

10566 tool = ToolUpdate(**tool_data) # Pydantic validation happens here 

10567 

10568 # Get current tool to extract current version 

10569 current_tool = db.get(DbTool, tool_id) 

10570 current_version = getattr(current_tool, "version", 0) if current_tool else 0 

10571 

10572 # Extract modification metadata 

10573 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version) 

10574 

10575 await tool_service.update_tool( 

10576 db, 

10577 tool_id, 

10578 tool, 

10579 modified_by=mod_metadata["modified_by"], 

10580 modified_from_ip=mod_metadata["modified_from_ip"], 

10581 modified_via=mod_metadata["modified_via"], 

10582 modified_user_agent=mod_metadata["modified_user_agent"], 

10583 user_email=user_email, 

10584 ) 

10585 return ORJSONResponse(content={"message": "Edit tool successfully", "success": True}, status_code=200) 

10586 except PermissionError as e: 

10587 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}") 

10588 return ORJSONResponse( 

10589 content={"message": str(e), "success": False}, 

10590 status_code=403, 

10591 ) 

10592 except IntegrityError as ex: 

10593 error_message = ErrorFormatter.format_database_error(ex) 

10594 LOGGER.error(f"IntegrityError in admin_tool_resource: {error_message}") 

10595 return ORJSONResponse(status_code=409, content=error_message) 

10596 except ToolNameConflictError as ex: 

10597 LOGGER.error(f"ToolNameConflictError in admin_edit_tool: {str(ex)}") 

10598 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

10599 except ToolError as ex: 

10600 LOGGER.error(f"ToolError in admin_edit_tool: {str(ex)}") 

10601 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

10602 except ValidationError as ex: # Catch Pydantic validation errors 

10603 LOGGER.error(f"ValidationError in admin_edit_tool: {str(ex)}") 

10604 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

10605 except Exception as ex: # Generic catch-all for unexpected errors 

10606 LOGGER.error(f"Unexpected error in admin_edit_tool: {str(ex)}") 

10607 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

10608 

10609 

10610@admin_router.post("/tools/{tool_id}/delete") 

10611@require_permission("tools.delete", allow_admin_bypass=False) 

10612async def admin_delete_tool(tool_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse: 

10613 """ 

10614 Delete a tool via the admin UI. 

10615 

10616 This endpoint permanently removes a tool from the database using its ID. 

10617 It is irreversible and should be used with caution. The operation is logged, 

10618 and the user must be authenticated to access this route. 

10619 

10620 Args: 

10621 tool_id (str): The ID of the tool to delete. 

10622 request (Request): FastAPI request object (not used directly, but required by route signature). 

10623 db (Session): Database session dependency. 

10624 user (str): Authenticated user dependency. 

10625 

10626 Returns: 

10627 RedirectResponse: A redirect response to the tools section of the admin 

10628 dashboard with a status code of 303 (See Other). 

10629 

10630 Examples: 

10631 >>> callable(admin_delete_tool) 

10632 True 

10633 >>> admin_delete_tool.__name__ 

10634 'admin_delete_tool' 

10635 """ 

10636 form = await request.form() 

10637 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

10638 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true" 

10639 user_email = get_user_email(user) 

10640 LOGGER.debug(f"User {user_email} is deleting tool ID {tool_id}") 

10641 error_message = None 

10642 try: 

10643 await tool_service.delete_tool(db, tool_id, user_email=user_email, purge_metrics=purge_metrics) 

10644 except PermissionError as e: 

10645 LOGGER.warning(f"Permission denied for user {user_email} deleting tool {tool_id}: {e}") 

10646 error_message = str(e) 

10647 except Exception as e: 

10648 LOGGER.error(f"Error deleting tool: {e}") 

10649 error_message = "Failed to delete tool. Please try again." 

10650 

10651 root_path = request.scope.get("root_path", "") 

10652 

10653 # Build redirect URL with error message if present 

10654 if error_message: 

10655 error_param = f"?error={urllib.parse.quote(error_message)}" 

10656 if is_inactive_checked.lower() == "true": 

10657 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#tools", status_code=303) 

10658 return RedirectResponse(f"{root_path}/admin/{error_param}#tools", status_code=303) 

10659 

10660 if is_inactive_checked.lower() == "true": 

10661 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#tools", status_code=303) 

10662 return RedirectResponse(f"{root_path}/admin#tools", status_code=303) 

10663 

10664 

10665@admin_router.post("/tools/{tool_id}/state") 

10666@require_permission("tools.update", allow_admin_bypass=False) 

10667async def admin_set_tool_state( 

10668 tool_id: str, 

10669 request: Request, 

10670 db: Session = Depends(get_db), 

10671 user=Depends(get_current_user_with_permissions), 

10672) -> RedirectResponse: 

10673 """ 

10674 Toggle a tool's active status via the admin UI. 

10675 

10676 This endpoint processes a form request to activate or deactivate a tool. 

10677 It expects a form field 'activate' with value "true" to activate the tool 

10678 or "false" to deactivate it. The endpoint handles exceptions gracefully and 

10679 logs any errors that might occur during the status toggle operation. 

10680 

10681 Args: 

10682 tool_id (str): The ID of the tool whose status to toggle. 

10683 request (Request): FastAPI request containing form data with the 'activate' field. 

10684 db (Session): Database session dependency. 

10685 user (str): Authenticated user dependency. 

10686 

10687 Returns: 

10688 RedirectResponse: A redirect to the admin dashboard tools section with a 

10689 status code of 303 (See Other). 

10690 

10691 Examples: 

10692 >>> callable(admin_set_tool_state) 

10693 True 

10694 >>> admin_set_tool_state.__name__ 

10695 'admin_set_tool_state' 

10696 """ 

10697 error_message = None 

10698 user_email = get_user_email(user) 

10699 LOGGER.debug(f"User {user_email} is toggling tool ID {tool_id}") 

10700 form = await request.form() 

10701 activate = str(form.get("activate", "true")).lower() == "true" 

10702 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

10703 try: 

10704 await tool_service.set_tool_state(db, tool_id, activate, reachable=activate, user_email=user_email) 

10705 except PermissionError as e: 

10706 LOGGER.warning(f"Permission denied for user {user_email} setting tool state {tool_id}: {e}") 

10707 error_message = str(e) 

10708 except ToolLockConflictError as e: 

10709 LOGGER.warning(f"Lock conflict for user {user_email} setting tool {tool_id} state: {e}") 

10710 error_message = "Tool is being modified by another request. Please try again." 

10711 except Exception as e: 

10712 LOGGER.error(f"Error setting tool state: {e}") 

10713 error_message = "Failed to set tool state. Please try again." 

10714 

10715 root_path = request.scope.get("root_path", "") 

10716 

10717 # Build redirect URL with error message if present 

10718 if error_message: 

10719 error_param = f"?error={urllib.parse.quote(error_message)}" 

10720 if is_inactive_checked.lower() == "true": 

10721 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#tools", status_code=303) 

10722 return RedirectResponse(f"{root_path}/admin/{error_param}#tools", status_code=303) 

10723 

10724 if is_inactive_checked.lower() == "true": 

10725 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#tools", status_code=303) 

10726 return RedirectResponse(f"{root_path}/admin#tools", status_code=303) 

10727 

10728 

10729@admin_router.get("/gateways/{gateway_id}", response_model=GatewayRead) 

10730@require_permission("gateways.read", allow_admin_bypass=False) 

10731async def admin_get_gateway(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

10732 """Get gateway details for the admin UI. 

10733 

10734 Args: 

10735 gateway_id: Gateway ID. 

10736 db: Database session. 

10737 user: Authenticated user. 

10738 

10739 Returns: 

10740 Gateway details. 

10741 

10742 Raises: 

10743 HTTPException: If the gateway is not found. 

10744 Exception: For any other unexpected errors. 

10745 

10746 Examples: 

10747 >>> callable(admin_get_gateway) 

10748 True 

10749 >>> admin_get_gateway.__name__ 

10750 'admin_get_gateway' 

10751 """ 

10752 LOGGER.debug(f"User {get_user_email(user)} requested details for gateway ID {gateway_id}") 

10753 try: 

10754 gateway = await gateway_service.get_gateway(db, gateway_id) 

10755 return gateway.model_dump(by_alias=True) 

10756 except GatewayNotFoundError as e: 

10757 raise HTTPException(status_code=404, detail=str(e)) 

10758 except Exception as e: 

10759 LOGGER.error(f"Error getting gateway {gateway_id}: {e}") 

10760 raise e 

10761 

10762 

10763@admin_router.post("/gateways") 

10764@require_permission("gateways.create", allow_admin_bypass=False) 

10765async def admin_add_gateway(request: Request, db: Session = Depends(get_db), user: dict[str, Any] = Depends(get_current_user_with_permissions)) -> JSONResponse: 

10766 """Add a gateway via the admin UI. 

10767 

10768 Expects form fields: 

10769 - name 

10770 - url 

10771 - description (optional) 

10772 - tags (optional, comma-separated) 

10773 

10774 Args: 

10775 request: FastAPI request containing form data. 

10776 db: Database session. 

10777 user: Authenticated user. 

10778 

10779 Returns: 

10780 A redirect response to the admin dashboard. 

10781 

10782 Examples: 

10783 >>> callable(admin_add_gateway) 

10784 True 

10785 >>> admin_add_gateway.__name__ 

10786 'admin_add_gateway' 

10787 """ 

10788 LOGGER.debug(f"User {get_user_email(user)} is adding a new gateway") 

10789 form = await request.form() 

10790 try: 

10791 # Parse tags from comma-separated string 

10792 tags_str = str(form.get("tags", "")) 

10793 tags: list[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

10794 

10795 # Parse auth_headers JSON if present 

10796 auth_headers_json = str(form.get("auth_headers")) 

10797 auth_headers: list[dict[str, Any]] = [] 

10798 if auth_headers_json: 

10799 try: 

10800 auth_headers = orjson.loads(auth_headers_json) 

10801 except (orjson.JSONDecodeError, ValueError): 

10802 auth_headers = [] 

10803 

10804 # Parse OAuth configuration - support both JSON string and individual form fields 

10805 oauth_config_json = str(form.get("oauth_config")) 

10806 oauth_config: Optional[dict[str, Any]] = None 

10807 

10808 LOGGER.info(f"DEBUG: oauth_config_json from form = '{oauth_config_json}'") 

10809 LOGGER.info(f"DEBUG: Individual OAuth fields - grant_type='{form.get('oauth_grant_type')}', issuer='{form.get('oauth_issuer')}'") 

10810 

10811 # Option 1: Pre-assembled oauth_config JSON (from API calls) 

10812 if oauth_config_json and oauth_config_json != "None": 

10813 try: 

10814 oauth_config = orjson.loads(oauth_config_json) 

10815 # Encrypt the client secret if present 

10816 if oauth_config and "client_secret" in oauth_config: 

10817 encryption = get_encryption_service(settings.auth_encryption_secret) 

10818 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"]) 

10819 except (orjson.JSONDecodeError, ValueError) as e: 

10820 LOGGER.error(f"Failed to parse OAuth config: {e}") 

10821 oauth_config = None 

10822 

10823 # Option 2: Assemble from individual UI form fields 

10824 if not oauth_config: 

10825 oauth_grant_type = str(form.get("oauth_grant_type", "")) 

10826 oauth_issuer = str(form.get("oauth_issuer", "")) 

10827 oauth_token_url = str(form.get("oauth_token_url", "")) 

10828 oauth_authorization_url = str(form.get("oauth_authorization_url", "")) 

10829 oauth_redirect_uri = str(form.get("oauth_redirect_uri", "")) 

10830 oauth_client_id = str(form.get("oauth_client_id", "")) 

10831 oauth_client_secret = str(form.get("oauth_client_secret", "")) 

10832 oauth_username = str(form.get("oauth_username", "")) 

10833 oauth_password = str(form.get("oauth_password", "")) 

10834 oauth_scopes_str = str(form.get("oauth_scopes", "")) 

10835 

10836 # If any OAuth field is provided, assemble oauth_config 

10837 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]): 

10838 oauth_config = {} 

10839 

10840 if oauth_grant_type: 

10841 oauth_config["grant_type"] = oauth_grant_type 

10842 if oauth_issuer: 

10843 oauth_config["issuer"] = oauth_issuer 

10844 if oauth_token_url: 

10845 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint' 

10846 if oauth_authorization_url: 

10847 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint' 

10848 if oauth_redirect_uri: 

10849 oauth_config["redirect_uri"] = oauth_redirect_uri 

10850 if oauth_client_id: 

10851 oauth_config["client_id"] = oauth_client_id 

10852 if oauth_client_secret: 

10853 # Encrypt the client secret 

10854 encryption = get_encryption_service(settings.auth_encryption_secret) 

10855 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret) 

10856 

10857 # Add username and password for password grant type 

10858 if oauth_username: 

10859 oauth_config["username"] = oauth_username 

10860 if oauth_password: 

10861 oauth_config["password"] = oauth_password 

10862 

10863 # Parse scopes (comma or space separated) 

10864 if oauth_scopes_str: 

10865 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()] 

10866 if scopes: 

10867 oauth_config["scopes"] = scopes 

10868 

10869 LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}") 

10870 LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}") 

10871 

10872 visibility = str(form.get("visibility", "private")) 

10873 

10874 # Handle passthrough_headers 

10875 passthrough_headers = str(form.get("passthrough_headers")) 

10876 if passthrough_headers and passthrough_headers.strip(): 

10877 try: 

10878 passthrough_headers = orjson.loads(passthrough_headers) 

10879 except (orjson.JSONDecodeError, ValueError): 

10880 # Fallback to comma-separated parsing 

10881 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()] 

10882 else: 

10883 passthrough_headers = None 

10884 

10885 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth" 

10886 auth_type_from_form = str(form.get("auth_type", "")) 

10887 LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}") 

10888 if oauth_config and not auth_type_from_form: 

10889 auth_type_from_form = "oauth" 

10890 LOGGER.info("✅ Auto-detected OAuth configuration, setting auth_type='oauth'") 

10891 elif oauth_config and auth_type_from_form: 

10892 LOGGER.info(f"✅ OAuth config present with explicit auth_type='{auth_type_from_form}'") 

10893 

10894 ca_certificate: Optional[str] = None 

10895 sig: Optional[str] = None 

10896 

10897 # CA certificate(s) handled by JavaScript validation (supports single or multiple files) 

10898 # JavaScript validates, orders (root→intermediate→leaf), and concatenates into hidden field 

10899 if "ca_certificate" in form: 

10900 ca_cert_value = form["ca_certificate"] 

10901 if isinstance(ca_cert_value, str) and ca_cert_value.strip(): 

10902 ca_certificate = ca_cert_value.strip() 

10903 LOGGER.info("✅ CA certificate(s) received and validated by frontend") 

10904 

10905 if settings.enable_ed25519_signing: 

10906 try: 

10907 private_key_pem = settings.ed25519_private_key.get_secret_value() 

10908 sig = sign_data(ca_certificate.encode(), private_key_pem) 

10909 except Exception as e: 

10910 LOGGER.error(f"Error signing CA certificate: {e}") 

10911 sig = None 

10912 raise RuntimeError("Failed to sign CA certificate") from e 

10913 else: 

10914 LOGGER.warning("⚠️ Ed25519 signing is disabled; CA certificate will be stored without signature") 

10915 sig = None 

10916 

10917 gateway = GatewayCreate( 

10918 name=str(form["name"]), 

10919 url=str(form["url"]), 

10920 description=str(form.get("description")), 

10921 tags=tags, 

10922 transport=str(form.get("transport", "SSE")), 

10923 auth_type=auth_type_from_form, 

10924 auth_username=str(form.get("auth_username", "")), 

10925 auth_password=str(form.get("auth_password", "")), 

10926 auth_token=str(form.get("auth_token", "")), 

10927 auth_header_key=str(form.get("auth_header_key", "")), 

10928 auth_header_value=str(form.get("auth_header_value", "")), 

10929 auth_headers=auth_headers if auth_headers else None, 

10930 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None, 

10931 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None, 

10932 oauth_config=oauth_config, 

10933 one_time_auth=form.get("one_time_auth", False), 

10934 passthrough_headers=passthrough_headers, 

10935 visibility=visibility, 

10936 ca_certificate=ca_certificate, 

10937 ca_certificate_sig=sig if sig else None, 

10938 signing_algorithm="ed25519" if sig else None, 

10939 ) 

10940 except KeyError as e: 

10941 # Convert KeyError to ValidationError-like response 

10942 return ORJSONResponse(content={"message": f"Missing required field: {e}", "success": False}, status_code=422) 

10943 

10944 except ValidationError as ex: 

10945 # --- Getting only the custom message from the ValueError --- 

10946 error_ctx = [str(err["ctx"]["error"]) for err in ex.errors()] 

10947 return ORJSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422) 

10948 

10949 except RuntimeError as err: 

10950 # --- Getting only the custom message from the RuntimeError --- 

10951 error_ctx = [str(err)] 

10952 return ORJSONResponse(content={"success": False, "message": "; ".join(error_ctx)}, status_code=422) 

10953 

10954 user_email = get_user_email(user) 

10955 team_id = form.get("team_id", None) 

10956 

10957 team_service = TeamManagementService(db) 

10958 team_id = await team_service.verify_team_for_user(user_email, team_id) 

10959 

10960 try: 

10961 # Extract creation metadata 

10962 metadata = MetadataCapture.extract_creation_metadata(request, user) 

10963 

10964 team_id_cast = typing_cast(Optional[str], team_id) 

10965 await gateway_service.register_gateway( 

10966 db, 

10967 gateway, 

10968 created_by=metadata["created_by"], 

10969 created_from_ip=metadata["created_from_ip"], 

10970 created_via=metadata["created_via"], 

10971 created_user_agent=metadata["created_user_agent"], 

10972 visibility=visibility, 

10973 team_id=team_id_cast, 

10974 owner_email=user_email, 

10975 initialize_timeout=settings.httpx_admin_read_timeout, 

10976 ) 

10977 

10978 # Provide specific guidance for OAuth Authorization Code flow 

10979 message = "Gateway registered successfully!" 

10980 if oauth_config and isinstance(oauth_config, dict) and oauth_config.get("grant_type") == "authorization_code": 

10981 message = ( 

10982 "Gateway registered successfully! 🎉\n\n" 

10983 "⚠️ IMPORTANT: This gateway uses OAuth Authorization Code flow.\n" 

10984 "You must complete the OAuth authorization before tools will work:\n\n" 

10985 "1. Go to the Gateways list\n" 

10986 "2. Click the '🔐 Authorize' button for this gateway\n" 

10987 "3. Complete the OAuth consent flow\n" 

10988 "4. Return to the admin panel\n\n" 

10989 "Tools will not work until OAuth authorization is completed." 

10990 ) 

10991 return ORJSONResponse( 

10992 content={"message": message, "success": True}, 

10993 status_code=200, 

10994 ) 

10995 

10996 except GatewayConnectionError as ex: 

10997 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=502) 

10998 except GatewayDuplicateConflictError as ex: 

10999 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

11000 except GatewayNameConflictError as ex: 

11001 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

11002 except RuntimeError as ex: 

11003 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11004 except ValidationError as ex: 

11005 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

11006 # NOTE: Pydantic's ValidationError subclasses ValueError, so ValidationError must be handled first. 

11007 except ValueError as ex: 

11008 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400) 

11009 except IntegrityError as ex: 

11010 return ORJSONResponse(content=ErrorFormatter.format_database_error(ex), status_code=409) 

11011 except Exception as ex: 

11012 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11013 

11014 

11015# OAuth callback is now handled by the dedicated OAuth router at /oauth/callback 

11016# This route has been removed to avoid conflicts with the complete implementation 

11017@admin_router.post("/gateways/{gateway_id}/edit") 

11018@require_permission("gateways.update", allow_admin_bypass=False) 

11019async def admin_edit_gateway( 

11020 gateway_id: str, 

11021 request: Request, 

11022 db: Session = Depends(get_db), 

11023 user=Depends(get_current_user_with_permissions), 

11024) -> JSONResponse: 

11025 """Edit a gateway via the admin UI. 

11026 

11027 Expects form fields: 

11028 - name 

11029 - url 

11030 - description (optional) 

11031 - tags (optional, comma-separated) 

11032 

11033 Args: 

11034 gateway_id: Gateway ID. 

11035 request: FastAPI request containing form data. 

11036 db: Database session. 

11037 user: Authenticated user. 

11038 

11039 Returns: 

11040 A redirect response to the admin dashboard. 

11041 

11042 Examples: 

11043 >>> callable(admin_edit_gateway) 

11044 True 

11045 >>> admin_edit_gateway.__name__ 

11046 'admin_edit_gateway' 

11047 """ 

11048 LOGGER.debug(f"User {get_user_email(user)} is editing gateway ID {gateway_id}") 

11049 form = await request.form() 

11050 try: 

11051 # Parse tags from comma-separated string 

11052 tags_str = str(form.get("tags", "")) 

11053 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

11054 

11055 visibility = str(form.get("visibility", "private")) 

11056 

11057 # Parse auth_headers JSON if present 

11058 auth_headers_json = str(form.get("auth_headers")) 

11059 auth_headers = [] 

11060 if auth_headers_json: 

11061 try: 

11062 auth_headers = orjson.loads(auth_headers_json) 

11063 except (orjson.JSONDecodeError, ValueError): 

11064 auth_headers = [] 

11065 

11066 # Handle passthrough_headers 

11067 passthrough_headers = str(form.get("passthrough_headers")) 

11068 if passthrough_headers and passthrough_headers.strip(): 

11069 try: 

11070 passthrough_headers = orjson.loads(passthrough_headers) 

11071 except (orjson.JSONDecodeError, ValueError): 

11072 # Fallback to comma-separated parsing 

11073 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()] 

11074 else: 

11075 passthrough_headers = None 

11076 

11077 # Parse OAuth configuration - support both JSON string and individual form fields 

11078 oauth_config_json = str(form.get("oauth_config")) 

11079 oauth_config: Optional[dict[str, Any]] = None 

11080 

11081 # Option 1: Pre-assembled oauth_config JSON (from API calls) 

11082 if oauth_config_json and oauth_config_json != "None": 

11083 try: 

11084 oauth_config = orjson.loads(oauth_config_json) 

11085 # Encrypt the client secret if present and not empty 

11086 if oauth_config and "client_secret" in oauth_config and oauth_config["client_secret"]: 

11087 encryption = get_encryption_service(settings.auth_encryption_secret) 

11088 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"]) 

11089 except (orjson.JSONDecodeError, ValueError) as e: 

11090 LOGGER.error(f"Failed to parse OAuth config: {e}") 

11091 oauth_config = None 

11092 

11093 # Option 2: Assemble from individual UI form fields 

11094 if not oauth_config: 

11095 oauth_grant_type = str(form.get("oauth_grant_type", "")) 

11096 oauth_issuer = str(form.get("oauth_issuer", "")) 

11097 oauth_token_url = str(form.get("oauth_token_url", "")) 

11098 oauth_authorization_url = str(form.get("oauth_authorization_url", "")) 

11099 oauth_redirect_uri = str(form.get("oauth_redirect_uri", "")) 

11100 oauth_client_id = str(form.get("oauth_client_id", "")) 

11101 oauth_client_secret = str(form.get("oauth_client_secret", "")) 

11102 oauth_username = str(form.get("oauth_username", "")) 

11103 oauth_password = str(form.get("oauth_password", "")) 

11104 oauth_scopes_str = str(form.get("oauth_scopes", "")) 

11105 

11106 # If any OAuth field is provided, assemble oauth_config 

11107 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]): 

11108 oauth_config = {} 

11109 

11110 if oauth_grant_type: 

11111 oauth_config["grant_type"] = oauth_grant_type 

11112 if oauth_issuer: 

11113 oauth_config["issuer"] = oauth_issuer 

11114 if oauth_token_url: 

11115 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint' 

11116 if oauth_authorization_url: 

11117 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint' 

11118 if oauth_redirect_uri: 

11119 oauth_config["redirect_uri"] = oauth_redirect_uri 

11120 if oauth_client_id: 

11121 oauth_config["client_id"] = oauth_client_id 

11122 if oauth_client_secret: 

11123 # Encrypt the client secret 

11124 encryption = get_encryption_service(settings.auth_encryption_secret) 

11125 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret) 

11126 

11127 # Add username and password for password grant type 

11128 if oauth_username: 

11129 oauth_config["username"] = oauth_username 

11130 if oauth_password: 

11131 oauth_config["password"] = oauth_password 

11132 

11133 # Parse scopes (comma or space separated) 

11134 if oauth_scopes_str: 

11135 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()] 

11136 if scopes: 

11137 oauth_config["scopes"] = scopes 

11138 

11139 LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}") 

11140 

11141 user_email = get_user_email(user) 

11142 # Determine personal team for default assignment 

11143 team_id_raw = form.get("team_id", None) 

11144 team_id = str(team_id_raw) if team_id_raw is not None else None 

11145 

11146 team_service = TeamManagementService(db) 

11147 team_id = await team_service.verify_team_for_user(user_email, team_id) 

11148 

11149 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth" 

11150 auth_type_from_form = str(form.get("auth_type", "")) 

11151 if oauth_config and not auth_type_from_form: 

11152 auth_type_from_form = "oauth" 

11153 LOGGER.info("Auto-detected OAuth configuration in edit, setting auth_type='oauth'") 

11154 

11155 gateway = GatewayUpdate( # Pydantic validation happens here 

11156 name=str(form.get("name")), 

11157 url=str(form["url"]), 

11158 description=str(form.get("description")), 

11159 transport=str(form.get("transport", "SSE")), 

11160 tags=tags, 

11161 auth_type=auth_type_from_form, 

11162 auth_username=str(form.get("auth_username", "")), 

11163 auth_password=str(form.get("auth_password", "")), 

11164 auth_token=str(form.get("auth_token", "")), 

11165 auth_header_key=str(form.get("auth_header_key", "")), 

11166 auth_header_value=str(form.get("auth_header_value", "")), 

11167 auth_value=str(form.get("auth_value", "")), 

11168 auth_headers=auth_headers if auth_headers else None, 

11169 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None, 

11170 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None, 

11171 one_time_auth=form.get("one_time_auth", False), 

11172 passthrough_headers=passthrough_headers, 

11173 oauth_config=oauth_config, 

11174 visibility=visibility, 

11175 owner_email=user_email, 

11176 team_id=team_id, 

11177 ) 

11178 

11179 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) 

11180 await gateway_service.update_gateway( 

11181 db, 

11182 gateway_id, 

11183 gateway, 

11184 modified_by=mod_metadata["modified_by"], 

11185 modified_from_ip=mod_metadata["modified_from_ip"], 

11186 modified_via=mod_metadata["modified_via"], 

11187 modified_user_agent=mod_metadata["modified_user_agent"], 

11188 user_email=user_email, 

11189 ) 

11190 return ORJSONResponse( 

11191 content={"message": "Gateway updated successfully!", "success": True}, 

11192 status_code=200, 

11193 ) 

11194 except PermissionError as e: 

11195 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}") 

11196 return ORJSONResponse( 

11197 content={"message": str(e), "success": False}, 

11198 status_code=403, 

11199 ) 

11200 except Exception as ex: 

11201 if isinstance(ex, GatewayConnectionError): 

11202 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=502) 

11203 if isinstance(ex, RuntimeError): 

11204 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11205 if isinstance(ex, ValidationError): 

11206 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

11207 if isinstance(ex, IntegrityError): 

11208 return ORJSONResponse(status_code=409, content=ErrorFormatter.format_database_error(ex)) 

11209 # NOTE: Pydantic's ValidationError subclasses ValueError, so ValidationError must be handled first. 

11210 if isinstance(ex, ValueError): 

11211 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=400) 

11212 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11213 

11214 

11215@admin_router.post("/gateways/{gateway_id}/delete") 

11216@require_permission("gateways.delete", allow_admin_bypass=False) 

11217async def admin_delete_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse: 

11218 """ 

11219 Delete a gateway via the admin UI. 

11220 

11221 This endpoint removes a gateway from the database by its ID. The deletion is 

11222 permanent and cannot be undone. It requires authentication and logs the 

11223 operation for auditing purposes. 

11224 

11225 Args: 

11226 gateway_id (str): The ID of the gateway to delete. 

11227 request (Request): FastAPI request object (not used directly but required by the route signature). 

11228 db (Session): Database session dependency. 

11229 user (str): Authenticated user dependency. 

11230 

11231 Returns: 

11232 RedirectResponse: A redirect response to the gateways section of the admin 

11233 dashboard with a status code of 303 (See Other). 

11234 

11235 Examples: 

11236 >>> callable(admin_delete_gateway) 

11237 True 

11238 >>> admin_delete_gateway.__name__ 

11239 'admin_delete_gateway' 

11240 """ 

11241 user_email = get_user_email(user) 

11242 LOGGER.debug(f"User {user_email} is deleting gateway ID {gateway_id}") 

11243 error_message = None 

11244 try: 

11245 await gateway_service.delete_gateway(db, gateway_id, user_email=user_email) 

11246 except PermissionError as e: 

11247 LOGGER.warning(f"Permission denied for user {user_email} deleting gateway {gateway_id}: {e}") 

11248 error_message = str(e) 

11249 except Exception as e: 

11250 LOGGER.error(f"Error deleting gateway: {e}") 

11251 error_message = "Failed to delete gateway. Please try again." 

11252 

11253 form = await request.form() 

11254 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

11255 root_path = request.scope.get("root_path", "") 

11256 

11257 # Build redirect URL with error message if present 

11258 if error_message: 

11259 error_param = f"?error={urllib.parse.quote(error_message)}" 

11260 if is_inactive_checked.lower() == "true": 

11261 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#gateways", status_code=303) 

11262 return RedirectResponse(f"{root_path}/admin/{error_param}#gateways", status_code=303) 

11263 

11264 if is_inactive_checked.lower() == "true": 

11265 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#gateways", status_code=303) 

11266 return RedirectResponse(f"{root_path}/admin#gateways", status_code=303) 

11267 

11268 

11269@admin_router.get("/resources/test/{resource_uri:path}") 

11270@require_permission("resources.read", allow_admin_bypass=False) 

11271async def admin_test_resource(resource_uri: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

11272 """ 

11273 Test reading a resource by its URI for the admin UI. 

11274 

11275 Args: 

11276 resource_uri: The full resource URI (may include encoded characters). 

11277 db: Database session dependency. 

11278 user: Authenticated user with proper permissions. 

11279 

11280 Returns: 

11281 A dictionary containing the resolved resource content. 

11282 

11283 Raises: 

11284 HTTPException: If the resource is not found. 

11285 Exception: For unexpected errors. 

11286 

11287 Examples: 

11288 >>> callable(admin_test_resource) 

11289 True 

11290 >>> admin_test_resource.__name__ 

11291 'admin_test_resource' 

11292 """ 

11293 user_email = get_user_email(user) 

11294 LOGGER.debug(f"User {user_email} requested details for resource ID {resource_uri}") 

11295 

11296 # For admin UI, pass user email and token_teams=None 

11297 # Since admin UI requires admin permissions, the user should have full access 

11298 # via the admin bypass (is_admin + token_teams=None) 

11299 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False 

11300 

11301 try: 

11302 # Admin users get unrestricted access (user_email=None, token_teams=None) 

11303 # Non-admin users get team-based access (user_email=email, token_teams=None for lookup) 

11304 resource_content = await resource_service.read_resource( 

11305 db, 

11306 resource_uri=resource_uri, 

11307 user=None if is_admin else user_email, 

11308 token_teams=None, 

11309 ) 

11310 return {"content": resource_content} 

11311 except ResourceNotFoundError as e: 

11312 raise HTTPException(status_code=404, detail=str(e)) 

11313 except Exception as e: 

11314 LOGGER.error(f"Error getting resource for {resource_uri}: {e}") 

11315 raise e 

11316 

11317 

11318@admin_router.get("/resources/{resource_id}") 

11319@require_permission("resources.read", allow_admin_bypass=False) 

11320async def admin_get_resource(resource_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

11321 """Get resource details for the admin UI. 

11322 

11323 Args: 

11324 resource_id: Resource ID. 

11325 db: Database session. 

11326 user: Authenticated user. 

11327 

11328 Returns: 

11329 A dictionary containing resource details. 

11330 

11331 Raises: 

11332 HTTPException: If the resource is not found. 

11333 Exception: For any other unexpected errors. 

11334 

11335 Examples: 

11336 >>> callable(admin_get_resource) 

11337 True 

11338 >>> admin_get_resource.__name__ 

11339 'admin_get_resource' 

11340 """ 

11341 LOGGER.debug(f"User {get_user_email(user)} requested details for resource ID {resource_id}") 

11342 try: 

11343 resource = await resource_service.get_resource_by_id(db, resource_id, include_inactive=True) 

11344 # content = await resource_service.read_resource(db, resource_id=resource_id) 

11345 return {"resource": resource.model_dump(by_alias=True)} # , "content": None} 

11346 except ResourceNotFoundError as e: 

11347 raise HTTPException(status_code=404, detail=str(e)) 

11348 except Exception as e: 

11349 LOGGER.error(f"Error getting resource {resource_id}: {e}") 

11350 raise e 

11351 

11352 

11353@admin_router.post("/resources") 

11354@require_permission("resources.create", allow_admin_bypass=False) 

11355async def admin_add_resource(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Response: 

11356 """ 

11357 Add a resource via the admin UI. 

11358 

11359 Expects form fields: 

11360 - uri 

11361 - name 

11362 - description (optional) 

11363 - mime_type (optional) 

11364 - content 

11365 

11366 Args: 

11367 request: FastAPI request containing form data. 

11368 db: Database session. 

11369 user: Authenticated user. 

11370 

11371 Returns: 

11372 A redirect response to the admin dashboard. 

11373 

11374 Examples: 

11375 >>> callable(admin_add_resource) 

11376 True 

11377 >>> admin_add_resource.__name__ 

11378 'admin_add_resource' 

11379 """ 

11380 LOGGER.debug(f"User {get_user_email(user)} is adding a new resource") 

11381 form = await request.form() 

11382 

11383 # Parse tags from comma-separated string 

11384 tags_str = str(form.get("tags", "")) 

11385 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

11386 visibility = str(form.get("visibility", "public")) 

11387 user_email = get_user_email(user) 

11388 # Determine personal team for default assignment 

11389 team_id = form.get("team_id", None) 

11390 team_service = TeamManagementService(db) 

11391 team_id = await team_service.verify_team_for_user(user_email, team_id) 

11392 

11393 try: 

11394 # Handle template field: convert empty string to None for optional field 

11395 template = None 

11396 template_value = form.get("uri_template") 

11397 template = template_value if template_value else None 

11398 template_value = form.get("uri_template") 

11399 uri_value = form.get("uri") 

11400 

11401 # Ensure uri_value is a string 

11402 if isinstance(uri_value, str) and "{" in uri_value and "}" in uri_value: 

11403 template = uri_value 

11404 

11405 resource = ResourceCreate( 

11406 uri=str(form["uri"]), 

11407 name=str(form["name"]), 

11408 description=str(form.get("description", "")), 

11409 mime_type=str(form.get("mimeType", "")), 

11410 uri_template=template, 

11411 content=str(form["content"]), 

11412 tags=tags, 

11413 visibility=visibility, 

11414 team_id=team_id, 

11415 owner_email=user_email, 

11416 ) 

11417 

11418 metadata = MetadataCapture.extract_creation_metadata(request, user) 

11419 

11420 await resource_service.register_resource( 

11421 db, 

11422 resource, 

11423 created_by=metadata["created_by"], 

11424 created_from_ip=metadata["created_from_ip"], 

11425 created_via=metadata["created_via"], 

11426 created_user_agent=metadata["created_user_agent"], 

11427 import_batch_id=metadata["import_batch_id"], 

11428 federation_source=metadata["federation_source"], 

11429 team_id=team_id, 

11430 owner_email=user_email, 

11431 visibility=visibility, 

11432 ) 

11433 return ORJSONResponse( 

11434 content={"message": "Add resource registered successfully!", "success": True}, 

11435 status_code=200, 

11436 ) 

11437 except Exception as ex: 

11438 # Roll back only when a transaction is active to avoid sqlite3 "no transaction" errors. 

11439 try: 

11440 active_transaction = db.get_transaction() if hasattr(db, "get_transaction") else None 

11441 if db.is_active and active_transaction is not None: 

11442 db.rollback() 

11443 except (InvalidRequestError, OperationalError) as rollback_error: 

11444 LOGGER.warning( 

11445 "Rollback failed (ignoring for SQLite compatibility): %s", 

11446 rollback_error, 

11447 ) 

11448 

11449 if isinstance(ex, ValidationError): 

11450 LOGGER.error(f"ValidationError in admin_add_resource: {ErrorFormatter.format_validation_error(ex)}") 

11451 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

11452 if isinstance(ex, IntegrityError): 

11453 error_message = ErrorFormatter.format_database_error(ex) 

11454 LOGGER.error(f"IntegrityError in admin_add_resource: {error_message}") 

11455 return ORJSONResponse(status_code=409, content=error_message) 

11456 if isinstance(ex, ResourceURIConflictError): 

11457 LOGGER.error(f"ResourceURIConflictError in admin_add_resource: {ex}") 

11458 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

11459 LOGGER.error(f"Error in admin_add_resource: {ex}") 

11460 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11461 

11462 

11463@admin_router.post("/resources/{resource_id}/edit") 

11464@require_permission("resources.update", allow_admin_bypass=False) 

11465async def admin_edit_resource( 

11466 resource_id: str, 

11467 request: Request, 

11468 db: Session = Depends(get_db), 

11469 user=Depends(get_current_user_with_permissions), 

11470) -> JSONResponse: 

11471 """ 

11472 Edit a resource via the admin UI. 

11473 

11474 Expects form fields: 

11475 - name 

11476 - description (optional) 

11477 - mime_type (optional) 

11478 - content 

11479 

11480 Args: 

11481 resource_id: Resource ID. 

11482 request: FastAPI request containing form data. 

11483 db: Database session. 

11484 user: Authenticated user. 

11485 

11486 Returns: 

11487 JSONResponse: A JSON response indicating success or failure of the resource update operation. 

11488 

11489 Examples: 

11490 >>> callable(admin_edit_resource) 

11491 True 

11492 >>> admin_edit_resource.__name__ 

11493 'admin_edit_resource' 

11494 """ 

11495 LOGGER.debug(f"User {get_user_email(user)} is editing resource ID {resource_id}") 

11496 form = await request.form() 

11497 LOGGER.info(f"Form data received for resource edit: {form}") 

11498 visibility = str(form.get("visibility", "private")) 

11499 # Parse tags from comma-separated string 

11500 tags_str = str(form.get("tags", "")) 

11501 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

11502 

11503 try: 

11504 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) 

11505 resource = ResourceUpdate( 

11506 uri=str(form.get("uri", "")), 

11507 name=str(form.get("name", "")), 

11508 description=str(form.get("description")), 

11509 mime_type=str(form.get("mimeType")), 

11510 content=str(form.get("content", "")), 

11511 template=str(form.get("template")), 

11512 tags=tags, 

11513 visibility=visibility, 

11514 ) 

11515 LOGGER.info(f"ResourceUpdate object created: {resource}") 

11516 await resource_service.update_resource( 

11517 db, 

11518 resource_id, 

11519 resource, 

11520 modified_by=mod_metadata["modified_by"], 

11521 modified_from_ip=mod_metadata["modified_from_ip"], 

11522 modified_via=mod_metadata["modified_via"], 

11523 modified_user_agent=mod_metadata["modified_user_agent"], 

11524 user_email=get_user_email(user), 

11525 ) 

11526 return ORJSONResponse( 

11527 content={"message": "Resource updated successfully!", "success": True}, 

11528 status_code=200, 

11529 ) 

11530 except PermissionError as e: 

11531 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}") 

11532 return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403) 

11533 except Exception as ex: 

11534 if isinstance(ex, ValidationError): 

11535 LOGGER.error(f"ValidationError in admin_edit_resource: {ErrorFormatter.format_validation_error(ex)}") 

11536 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

11537 if isinstance(ex, IntegrityError): 

11538 error_message = ErrorFormatter.format_database_error(ex) 

11539 LOGGER.error(f"IntegrityError in admin_edit_resource: {error_message}") 

11540 return ORJSONResponse(status_code=409, content=error_message) 

11541 if isinstance(ex, ResourceURIConflictError): 

11542 LOGGER.error(f"ResourceURIConflictError in admin_edit_resource: {ex}") 

11543 return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False}) 

11544 LOGGER.error(f"Error in admin_edit_resource: {ex}") 

11545 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11546 

11547 

11548@admin_router.post("/resources/{resource_id}/delete") 

11549@require_permission("resources.delete", allow_admin_bypass=False) 

11550async def admin_delete_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse: 

11551 """ 

11552 Delete a resource via the admin UI. 

11553 

11554 This endpoint permanently removes a resource from the database using its resource ID. 

11555 The operation is irreversible and should be used with caution. It requires 

11556 user authentication and logs the deletion attempt. 

11557 

11558 Args: 

11559 resource_id (str): The ID of the resource to delete. 

11560 request (Request): FastAPI request object (not used directly but required by the route signature). 

11561 db (Session): Database session dependency. 

11562 user (str): Authenticated user dependency. 

11563 

11564 Returns: 

11565 RedirectResponse: A redirect response to the resources section of the admin 

11566 dashboard with a status code of 303 (See Other). 

11567 

11568 Examples: 

11569 >>> callable(admin_delete_resource) 

11570 True 

11571 >>> admin_delete_resource.__name__ 

11572 'admin_delete_resource' 

11573 """ 

11574 

11575 form = await request.form() 

11576 is_inactive_checked: str = str(form.get("is_inactive_checked", "false")) 

11577 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true" 

11578 user_email = get_user_email(user) 

11579 LOGGER.debug(f"User {get_user_email(user)} is deleting resource ID {resource_id}") 

11580 error_message = None 

11581 try: 

11582 await resource_service.delete_resource( 

11583 db, # Use endpoint's db session (user["db"] is now closed early) 

11584 resource_id, 

11585 user_email=user_email, 

11586 purge_metrics=purge_metrics, 

11587 ) 

11588 except PermissionError as e: 

11589 LOGGER.warning(f"Permission denied for user {user_email} deleting resource {resource_id}: {e}") 

11590 error_message = str(e) 

11591 except Exception as e: 

11592 LOGGER.error(f"Error deleting resource: {e}") 

11593 error_message = "Failed to delete resource. Please try again." 

11594 root_path = request.scope.get("root_path", "") 

11595 

11596 # Build redirect URL with error message if present 

11597 if error_message: 

11598 error_param = f"?error={urllib.parse.quote(error_message)}" 

11599 if is_inactive_checked.lower() == "true": 

11600 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#resources", status_code=303) 

11601 return RedirectResponse(f"{root_path}/admin/{error_param}#resources", status_code=303) 

11602 

11603 if is_inactive_checked.lower() == "true": 

11604 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#resources", status_code=303) 

11605 return RedirectResponse(f"{root_path}/admin#resources", status_code=303) 

11606 

11607 

11608@admin_router.post("/resources/{resource_id}/state") 

11609@require_permission("resources.update", allow_admin_bypass=False) 

11610async def admin_set_resource_state( 

11611 resource_id: str, 

11612 request: Request, 

11613 db: Session = Depends(get_db), 

11614 user=Depends(get_current_user_with_permissions), 

11615) -> RedirectResponse: 

11616 """ 

11617 Toggle a resource's active status via the admin UI. 

11618 

11619 This endpoint processes a form request to activate or deactivate a resource. 

11620 It expects a form field 'activate' with value "true" to activate the resource 

11621 or "false" to deactivate it. The endpoint handles exceptions gracefully and 

11622 logs any errors that might occur during the status toggle operation. 

11623 

11624 Args: 

11625 resource_id (str): The ID of the resource whose status to toggle. 

11626 request (Request): FastAPI request containing form data with the 'activate' field. 

11627 db (Session): Database session dependency. 

11628 user (str): Authenticated user dependency. 

11629 

11630 Returns: 

11631 RedirectResponse: A redirect to the admin dashboard resources section with a 

11632 status code of 303 (See Other). 

11633 

11634 Examples: 

11635 >>> callable(admin_set_resource_state) 

11636 True 

11637 >>> admin_set_resource_state.__name__ 

11638 'admin_set_resource_state' 

11639 """ 

11640 user_email = get_user_email(user) 

11641 LOGGER.debug(f"User {user_email} is toggling resource ID {resource_id}") 

11642 form = await request.form() 

11643 error_message = None 

11644 activate = str(form.get("activate", "true")).lower() == "true" 

11645 is_inactive_checked = str(form.get("is_inactive_checked", "false")) 

11646 try: 

11647 await resource_service.set_resource_state(db, resource_id, activate, user_email=user_email) 

11648 except PermissionError as e: 

11649 LOGGER.warning(f"Permission denied for user {user_email} setting resource state {resource_id}: {e}") 

11650 error_message = str(e) 

11651 except Exception as e: 

11652 LOGGER.error(f"Error setting resource state: {e}") 

11653 error_message = "Failed to set resource state. Please try again." 

11654 

11655 root_path = request.scope.get("root_path", "") 

11656 

11657 # Build redirect URL with error message if present 

11658 if error_message: 

11659 error_param = f"?error={urllib.parse.quote(error_message)}" 

11660 if is_inactive_checked.lower() == "true": 

11661 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#resources", status_code=303) 

11662 return RedirectResponse(f"{root_path}/admin/{error_param}#resources", status_code=303) 

11663 

11664 if is_inactive_checked.lower() == "true": 

11665 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#resources", status_code=303) 

11666 return RedirectResponse(f"{root_path}/admin#resources", status_code=303) 

11667 

11668 

11669@admin_router.get("/prompts/{prompt_id}") 

11670@require_permission("prompts.read", allow_admin_bypass=False) 

11671async def admin_get_prompt(prompt_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]: 

11672 """Get prompt details for the admin UI. 

11673 

11674 Args: 

11675 prompt_id: Prompt ID. 

11676 db: Database session. 

11677 user: Authenticated user. 

11678 

11679 Returns: 

11680 A dictionary with prompt details. 

11681 

11682 Raises: 

11683 HTTPException: If the prompt is not found. 

11684 Exception: For any other unexpected errors. 

11685 

11686 Examples: 

11687 >>> callable(admin_get_prompt) 

11688 True 

11689 >>> admin_get_prompt.__name__ 

11690 'admin_get_prompt' 

11691 """ 

11692 LOGGER.info(f"User {get_user_email(user)} requested details for prompt ID {prompt_id}") 

11693 try: 

11694 prompt_details = await prompt_service.get_prompt_details(db, prompt_id) 

11695 prompt = PromptRead.model_validate(prompt_details) 

11696 return prompt.model_dump(by_alias=True) 

11697 except PromptNotFoundError as e: 

11698 raise HTTPException(status_code=404, detail=str(e)) 

11699 except Exception as e: 

11700 LOGGER.error(f"Error getting prompt {prompt_id}: {e}") 

11701 raise 

11702 

11703 

11704@admin_router.post("/prompts") 

11705@require_permission("prompts.create", allow_admin_bypass=False) 

11706async def admin_add_prompt(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> JSONResponse: 

11707 """Add a prompt via the admin UI. 

11708 

11709 Expects form fields: 

11710 - name 

11711 - description (optional) 

11712 - template 

11713 - arguments (as a JSON string representing a list) 

11714 

11715 Args: 

11716 request: FastAPI request containing form data. 

11717 db: Database session. 

11718 user: Authenticated user. 

11719 

11720 Returns: 

11721 A redirect response to the admin dashboard. 

11722 

11723 Examples: 

11724 >>> callable(admin_add_prompt) 

11725 True 

11726 >>> admin_add_prompt.__name__ 

11727 'admin_add_prompt' 

11728 """ 

11729 LOGGER.debug(f"User {get_user_email(user)} is adding a new prompt") 

11730 form = await request.form() 

11731 visibility = str(form.get("visibility", "private")) 

11732 user_email = get_user_email(user) 

11733 # Determine personal team for default assignment 

11734 team_id = form.get("team_id", None) 

11735 team_service = TeamManagementService(db) 

11736 team_id = await team_service.verify_team_for_user(user_email, team_id) 

11737 

11738 # Parse tags from comma-separated string 

11739 tags_str = str(form.get("tags", "")) 

11740 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

11741 

11742 try: 

11743 args_json = "[]" 

11744 args_value = form.get("arguments") 

11745 if isinstance(args_value, str) and args_value.strip(): 

11746 args_json = args_value 

11747 arguments = orjson.loads(args_json) 

11748 prompt = PromptCreate( 

11749 name=str(form["name"]), 

11750 display_name=str(form.get("display_name") or form["name"]), 

11751 description=str(form.get("description")), 

11752 template=str(form["template"]), 

11753 arguments=arguments, 

11754 tags=tags, 

11755 visibility=visibility, 

11756 team_id=team_id, 

11757 owner_email=user_email, 

11758 ) 

11759 # Extract creation metadata 

11760 metadata = MetadataCapture.extract_creation_metadata(request, user) 

11761 

11762 await prompt_service.register_prompt( 

11763 db, 

11764 prompt, 

11765 created_by=metadata["created_by"], 

11766 created_from_ip=metadata["created_from_ip"], 

11767 created_via=metadata["created_via"], 

11768 created_user_agent=metadata["created_user_agent"], 

11769 import_batch_id=metadata["import_batch_id"], 

11770 federation_source=metadata["federation_source"], 

11771 team_id=team_id, 

11772 owner_email=user_email, 

11773 visibility=visibility, 

11774 ) 

11775 return ORJSONResponse( 

11776 content={"message": "Prompt registered successfully!", "success": True}, 

11777 status_code=200, 

11778 ) 

11779 except Exception as ex: 

11780 if isinstance(ex, ValidationError): 

11781 LOGGER.error(f"ValidationError in admin_add_prompt: {ErrorFormatter.format_validation_error(ex)}") 

11782 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

11783 if isinstance(ex, IntegrityError): 

11784 error_message = ErrorFormatter.format_database_error(ex) 

11785 LOGGER.error(f"IntegrityError in admin_add_prompt: {error_message}") 

11786 return ORJSONResponse(status_code=409, content=error_message) 

11787 if isinstance(ex, PromptNameConflictError): 

11788 LOGGER.error(f"PromptNameConflictError in admin_add_prompt: {ex}") 

11789 return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False}) 

11790 LOGGER.error(f"Error in admin_add_prompt: {ex}") 

11791 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11792 

11793 

11794@admin_router.post("/prompts/{prompt_id}/edit") 

11795@require_permission("prompts.update", allow_admin_bypass=False) 

11796async def admin_edit_prompt( 

11797 prompt_id: str, 

11798 request: Request, 

11799 db: Session = Depends(get_db), 

11800 user=Depends(get_current_user_with_permissions), 

11801) -> JSONResponse: 

11802 """Edit a prompt via the admin UI. 

11803 

11804 Expects form fields: 

11805 - name 

11806 - description (optional) 

11807 - template 

11808 - arguments (as a JSON string representing a list) 

11809 

11810 Args: 

11811 prompt_id: Prompt ID. 

11812 request: FastAPI request containing form data. 

11813 db: Database session. 

11814 user: Authenticated user. 

11815 

11816 Returns: 

11817 JSONResponse: A JSON response indicating success or failure of the server update operation. 

11818 

11819 Examples: 

11820 >>> callable(admin_edit_prompt) 

11821 True 

11822 >>> admin_edit_prompt.__name__ 

11823 'admin_edit_prompt' 

11824 """ 

11825 LOGGER.debug(f"User {get_user_email(user)} is editing prompt {prompt_id}") 

11826 form = await request.form() 

11827 

11828 visibility = str(form.get("visibility", "private")) 

11829 user_email = get_user_email(user) 

11830 # Determine personal team for default assignment 

11831 team_id = form.get("team_id", None) 

11832 LOGGER.info(f"befor Verifying team for user {user_email} with team_id {team_id}") 

11833 team_service = TeamManagementService(db) 

11834 team_id = await team_service.verify_team_for_user(user_email, team_id) 

11835 LOGGER.info(f"Verifying team for user {user_email} with team_id {team_id}") 

11836 

11837 args_json: str = str(form.get("arguments")) or "[]" 

11838 arguments = orjson.loads(args_json) 

11839 # Parse tags from comma-separated string 

11840 tags_str = str(form.get("tags", "")) 

11841 tags: List[str] = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

11842 try: 

11843 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) 

11844 prompt = PromptUpdate( 

11845 custom_name=str(form.get("customName") or form.get("name")), 

11846 display_name=str(form.get("displayName") or form.get("display_name") or form.get("name")), 

11847 description=str(form.get("description")), 

11848 template=str(form["template"]), 

11849 arguments=arguments, 

11850 tags=tags, 

11851 visibility=visibility, 

11852 team_id=team_id, 

11853 owner_email=user_email, 

11854 ) 

11855 await prompt_service.update_prompt( 

11856 db, 

11857 prompt_id, 

11858 prompt, 

11859 modified_by=mod_metadata["modified_by"], 

11860 modified_from_ip=mod_metadata["modified_from_ip"], 

11861 modified_via=mod_metadata["modified_via"], 

11862 modified_user_agent=mod_metadata["modified_user_agent"], 

11863 user_email=user_email, 

11864 ) 

11865 return ORJSONResponse( 

11866 content={"message": "Prompt updated successfully!", "success": True}, 

11867 status_code=200, 

11868 ) 

11869 except PermissionError as e: 

11870 LOGGER.info(f"Permission denied for user {get_user_email(user)}: {e}") 

11871 return ORJSONResponse(content={"message": str(e), "success": False}, status_code=403) 

11872 except Exception as ex: 

11873 if isinstance(ex, ValidationError): 

11874 LOGGER.error(f"ValidationError in admin_edit_prompt: {ErrorFormatter.format_validation_error(ex)}") 

11875 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=422) 

11876 if isinstance(ex, IntegrityError): 

11877 error_message = ErrorFormatter.format_database_error(ex) 

11878 LOGGER.error(f"IntegrityError in admin_edit_prompt: {error_message}") 

11879 return ORJSONResponse(status_code=409, content=error_message) 

11880 if isinstance(ex, PromptNameConflictError): 

11881 LOGGER.error(f"PromptNameConflictError in admin_edit_prompt: {ex}") 

11882 return ORJSONResponse(status_code=409, content={"message": str(ex), "success": False}) 

11883 LOGGER.error(f"Error in admin_edit_prompt: {ex}") 

11884 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

11885 

11886 

11887@admin_router.post("/prompts/{prompt_id}/delete") 

11888@require_permission("prompts.delete", allow_admin_bypass=False) 

11889async def admin_delete_prompt(prompt_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> RedirectResponse: 

11890 """ 

11891 Delete a prompt via the admin UI. 

11892 

11893 This endpoint permanently deletes a prompt from the database using its ID. 

11894 Deletion is irreversible and requires authentication. All actions are logged 

11895 for administrative auditing. 

11896 

11897 Args: 

11898 prompt_id (str): The ID of the prompt to delete. 

11899 request (Request): FastAPI request object (not used directly but required by the route signature). 

11900 db (Session): Database session dependency. 

11901 user (str): Authenticated user dependency. 

11902 

11903 Returns: 

11904 RedirectResponse: A redirect response to the prompts section of the admin 

11905 dashboard with a status code of 303 (See Other). 

11906 

11907 Examples: 

11908 >>> callable(admin_delete_prompt) 

11909 True 

11910 >>> admin_delete_prompt.__name__ 

11911 'admin_delete_prompt' 

11912 """ 

11913 form = await request.form() 

11914 is_inactive_checked: str = str(form.get("is_inactive_checked", "false")) 

11915 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true" 

11916 user_email = get_user_email(user) 

11917 LOGGER.info(f"User {get_user_email(user)} is deleting prompt id {prompt_id}") 

11918 error_message = None 

11919 try: 

11920 await prompt_service.delete_prompt(db, prompt_id, user_email=user_email, purge_metrics=purge_metrics) 

11921 except PermissionError as e: 

11922 LOGGER.warning(f"Permission denied for user {user_email} deleting prompt {prompt_id}: {e}") 

11923 error_message = str(e) 

11924 except Exception as e: 

11925 LOGGER.error(f"Error deleting prompt: {e}") 

11926 error_message = "Failed to delete prompt. Please try again." 

11927 root_path = request.scope.get("root_path", "") 

11928 

11929 # Build redirect URL with error message if present 

11930 if error_message: 

11931 error_param = f"?error={urllib.parse.quote(error_message)}" 

11932 if is_inactive_checked.lower() == "true": 

11933 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#prompts", status_code=303) 

11934 return RedirectResponse(f"{root_path}/admin/{error_param}#prompts", status_code=303) 

11935 

11936 if is_inactive_checked.lower() == "true": 

11937 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#prompts", status_code=303) 

11938 return RedirectResponse(f"{root_path}/admin#prompts", status_code=303) 

11939 

11940 

11941@admin_router.post("/prompts/{prompt_id}/state") 

11942@require_permission("prompts.update", allow_admin_bypass=False) 

11943async def admin_set_prompt_state( 

11944 prompt_id: str, 

11945 request: Request, 

11946 db: Session = Depends(get_db), 

11947 user=Depends(get_current_user_with_permissions), 

11948) -> RedirectResponse: 

11949 """ 

11950 Toggle a prompt's active status via the admin UI. 

11951 

11952 This endpoint processes a form request to activate or deactivate a prompt. 

11953 It expects a form field 'activate' with value "true" to activate the prompt 

11954 or "false" to deactivate it. The endpoint handles exceptions gracefully and 

11955 logs any errors that might occur during the status toggle operation. 

11956 

11957 Args: 

11958 prompt_id (str): The ID of the prompt whose status to toggle. 

11959 request (Request): FastAPI request containing form data with the 'activate' field. 

11960 db (Session): Database session dependency. 

11961 user (str): Authenticated user dependency. 

11962 

11963 Returns: 

11964 RedirectResponse: A redirect to the admin dashboard prompts section with a 

11965 status code of 303 (See Other). 

11966 

11967 Examples: 

11968 >>> callable(admin_set_prompt_state) 

11969 True 

11970 >>> admin_set_prompt_state.__name__ 

11971 'admin_set_prompt_state' 

11972 """ 

11973 user_email = get_user_email(user) 

11974 LOGGER.debug(f"User {user_email} is toggling prompt ID {prompt_id}") 

11975 error_message = None 

11976 form = await request.form() 

11977 activate: bool = str(form.get("activate", "true")).lower() == "true" 

11978 is_inactive_checked: str = str(form.get("is_inactive_checked", "false")) 

11979 try: 

11980 await prompt_service.set_prompt_state(db, prompt_id, activate, user_email=user_email) 

11981 except PermissionError as e: 

11982 LOGGER.warning(f"Permission denied for user {user_email} setting prompt state {prompt_id}: {e}") 

11983 error_message = str(e) 

11984 except Exception as e: 

11985 LOGGER.error(f"Error setting prompt state: {e}") 

11986 error_message = "Failed to set prompt state. Please try again." 

11987 

11988 root_path = request.scope.get("root_path", "") 

11989 

11990 # Build redirect URL with error message if present 

11991 if error_message: 

11992 error_param = f"?error={urllib.parse.quote(error_message)}" 

11993 if is_inactive_checked.lower() == "true": 

11994 return RedirectResponse(f"{root_path}/admin/{error_param}&include_inactive=true#prompts", status_code=303) 

11995 return RedirectResponse(f"{root_path}/admin/{error_param}#prompts", status_code=303) 

11996 

11997 if is_inactive_checked.lower() == "true": 

11998 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#prompts", status_code=303) 

11999 return RedirectResponse(f"{root_path}/admin#prompts", status_code=303) 

12000 

12001 

12002@admin_router.get("/roots/export") 

12003@require_permission("admin.system_config", allow_admin_bypass=False) 

12004async def admin_export_root( 

12005 uri: str, 

12006 user=Depends(get_current_user_with_permissions), 

12007): 

12008 """ 

12009 Export a single root configuration as JSON. 

12010 

12011 Args: 

12012 uri: Root URI to export (query parameter) 

12013 user: Authenticated user 

12014 

12015 Returns: 

12016 JSON file download with root configuration 

12017 

12018 Raises: 

12019 HTTPException: If root not found or export fails 

12020 """ 

12021 try: 

12022 LOGGER.info(f"Admin user {get_user_email(user)} requested root export for URI: {uri}") 

12023 

12024 # Get the root by URI 

12025 root = await root_service.get_root_by_uri(uri) 

12026 

12027 # Extract username from user 

12028 username = get_user_email(user) 

12029 

12030 # Create export data 

12031 export_data = { 

12032 "exported_at": datetime.now().isoformat(), 

12033 "exported_by": username, 

12034 "export_type": "root", 

12035 "version": "1.0", 

12036 "root": { 

12037 "uri": str(root.uri), 

12038 "name": root.name, 

12039 }, 

12040 } 

12041 

12042 # Generate filename - sanitize URI for filename 

12043 # Remove protocol and special characters 

12044 safe_uri = uri.replace("://", "_").replace("/", "_").replace("\\", "_") 

12045 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

12046 filename = f"root-export-{safe_uri}-{timestamp}.json" 

12047 

12048 # Return as downloadable file 

12049 content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode() 

12050 return Response( 

12051 content=content, 

12052 media_type="application/json", 

12053 headers={ 

12054 "Content-Disposition": f'attachment; filename="{filename}"', 

12055 }, 

12056 ) 

12057 

12058 except RootServiceNotFoundError as e: 

12059 LOGGER.error(f"Root not found for export by user {get_user_email(user)}: {str(e)}") 

12060 raise HTTPException(status_code=404, detail=str(e)) 

12061 except Exception as e: 

12062 LOGGER.error(f"Unexpected root export error for user {get_user_email(user)}: {str(e)}") 

12063 raise HTTPException(status_code=500, detail=f"Root export failed: {str(e)}") 

12064 

12065 

12066@admin_router.get("/roots/{uri:path}") 

12067@require_permission("admin.system_config", allow_admin_bypass=False) 

12068async def admin_get_root(uri: str, user=Depends(get_current_user_with_permissions)) -> dict: 

12069 """Get a specific root by URI via the admin UI. 

12070 

12071 This endpoint retrieves details for a specific root URI from the system. 

12072 It requires authentication and logs the operation for audit purposes. 

12073 

12074 Args: 

12075 uri (str): The URI of the root to retrieve. 

12076 user: Authenticated user dependency. 

12077 

12078 Returns: 

12079 dict: A dictionary containing the root information. 

12080 

12081 Raises: 

12082 HTTPException: If the root is not found. 

12083 Exception: For any other unexpected errors. 

12084 

12085 Examples: 

12086 >>> callable(admin_get_root) 

12087 True 

12088 >>> admin_get_root.__name__ 

12089 'admin_get_root' 

12090 """ 

12091 LOGGER.debug(f"User {get_user_email(user)} is retrieving root URI {uri}") 

12092 try: 

12093 root = await root_service.get_root_by_uri(uri) 

12094 return root.model_dump(by_alias=True) 

12095 except RootServiceNotFoundError as e: 

12096 raise HTTPException(status_code=404, detail=str(e)) 

12097 except Exception as e: 

12098 LOGGER.error(f"Error getting root {uri}: {e}") 

12099 raise e 

12100 

12101 

12102@admin_router.post("/roots") 

12103@require_permission("admin.system_config", allow_admin_bypass=False) 

12104async def admin_add_root(request: Request, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)) -> RedirectResponse: 

12105 """Add a new root via the admin UI. 

12106 

12107 Expects form fields: 

12108 - uri 

12109 - name (optional) 

12110 

12111 Args: 

12112 request: FastAPI request containing form data. 

12113 user: Authenticated user. 

12114 _db: Database session for permission checks. 

12115 

12116 Returns: 

12117 RedirectResponse: A redirect response to the admin dashboard. 

12118 

12119 Examples: 

12120 >>> callable(admin_add_root) 

12121 True 

12122 >>> admin_add_root.__name__ 

12123 'admin_add_root' 

12124 """ 

12125 error_message = None 

12126 user_email = get_user_email(user) 

12127 LOGGER.debug(f"User {user_email} is adding a new root") 

12128 

12129 form = await request.form() 

12130 uri = str(form.get("uri", "")) 

12131 name_value = form.get("name") 

12132 name: str | None = None 

12133 if isinstance(name_value, str) and name_value.strip(): 

12134 name = name_value.strip() 

12135 

12136 try: 

12137 if not uri: 

12138 raise ValueError("URI is required") 

12139 await root_service.add_root(str(uri), name) 

12140 

12141 except RootServiceError as e: 

12142 LOGGER.warning(f"Failed to add root for user {user_email}: {e}") 

12143 error_message = "Failed to add root. Please check the URI format." 

12144 except ValueError as e: 

12145 LOGGER.warning(f"Invalid input from user {user_email}: {e}") 

12146 error_message = "Invalid input. Please try again." 

12147 except Exception as e: 

12148 LOGGER.error(f"Error adding root: {e}") 

12149 error_message = "Failed to add root. Please try again." 

12150 

12151 root_path = request.scope.get("root_path", "") 

12152 

12153 # Build redirect URL with error message if present 

12154 if error_message: 

12155 error_param = f"?error={urllib.parse.quote(error_message)}" 

12156 return RedirectResponse(f"{root_path}/admin{error_param}#roots", status_code=303) 

12157 

12158 return RedirectResponse(f"{root_path}/admin#roots", status_code=303) 

12159 

12160 

12161@admin_router.post("/roots/{uri:path}/update") 

12162@require_permission("admin.system_config", allow_admin_bypass=False) 

12163async def admin_update_root(uri: str, request: Request, user=Depends(get_current_user_with_permissions)) -> RedirectResponse: 

12164 """Update a root via the admin UI. 

12165 

12166 This endpoint updates an existing root URI in the system. It expects form 

12167 fields for the new values and requires authentication. 

12168 

12169 Expects form fields: 

12170 - name (optional): New name for the root 

12171 - is_inactive_checked: Whether the root should be marked as inactive 

12172 

12173 Args: 

12174 uri (str): The URI of the root to update. 

12175 request (Request): FastAPI request object containing form data. 

12176 user: Authenticated user dependency. 

12177 

12178 Returns: 

12179 RedirectResponse: A redirect response to the roots section of the admin 

12180 dashboard with a status code of 303 (See Other). 

12181 

12182 Raises: 

12183 HTTPException: If the root is not found (404) or other errors occur. 

12184 Exception: For any other unexpected errors. 

12185 

12186 Examples: 

12187 >>> callable(admin_update_root) 

12188 True 

12189 >>> admin_update_root.__name__ 

12190 'admin_update_root' 

12191 """ 

12192 LOGGER.debug(f"User {get_user_email(user)} is updating root URI {uri}") 

12193 

12194 try: 

12195 form = await request.form() 

12196 name_value = form.get("name") 

12197 name: str | None = None 

12198 

12199 if isinstance(name_value, str): 

12200 name = name_value 

12201 

12202 await root_service.update_root(uri, name) 

12203 

12204 root_path = request.scope.get("root_path", "") 

12205 is_inactive_checked: str = str(form.get("is_inactive_checked", "false")) 

12206 

12207 if is_inactive_checked.lower() == "true": 

12208 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#roots", status_code=303) 

12209 return RedirectResponse(f"{root_path}/admin#roots", status_code=303) 

12210 

12211 except RootServiceNotFoundError as e: 

12212 raise HTTPException(status_code=404, detail=str(e)) 

12213 except Exception as e: 

12214 LOGGER.error(f"Error updating root {uri}: {e}") 

12215 raise e 

12216 

12217 

12218@admin_router.post("/roots/{uri:path}/delete") 

12219@require_permission("admin.system_config", allow_admin_bypass=False) 

12220async def admin_delete_root(uri: str, request: Request, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)) -> RedirectResponse: 

12221 """ 

12222 Delete a root via the admin UI. 

12223 

12224 This endpoint removes a registered root URI from the system. The deletion is 

12225 permanent and cannot be undone. It requires authentication and logs the 

12226 operation for audit purposes. 

12227 

12228 Args: 

12229 uri (str): The URI of the root to delete. 

12230 request (Request): FastAPI request object (not used directly but required by the route signature). 

12231 user (str): Authenticated user dependency. 

12232 _db: Database session for permission checks. 

12233 

12234 Returns: 

12235 RedirectResponse: A redirect response to the roots section of the admin 

12236 dashboard with a status code of 303 (See Other). 

12237 

12238 Examples: 

12239 >>> callable(admin_delete_root) 

12240 True 

12241 >>> admin_delete_root.__name__ 

12242 'admin_delete_root' 

12243 """ 

12244 LOGGER.debug(f"User {get_user_email(user)} is deleting root URI {uri}") 

12245 await root_service.remove_root(uri) 

12246 form = await request.form() 

12247 root_path = request.scope.get("root_path", "") 

12248 is_inactive_checked: str = str(form.get("is_inactive_checked", "false")) 

12249 if is_inactive_checked.lower() == "true": 

12250 return RedirectResponse(f"{root_path}/admin/?include_inactive=true#roots", status_code=303) 

12251 return RedirectResponse(f"{root_path}/admin#roots", status_code=303) 

12252 

12253 

12254# Metrics 

12255MetricsDict = Dict[str, Union[ToolMetrics, ResourceMetrics, ServerMetrics, PromptMetrics]] 

12256 

12257 

12258# @admin_router.get("/metrics", response_model=MetricsDict) 

12259# async def admin_get_metrics( 

12260# db: Session = Depends(get_db), 

12261# user=Depends(get_current_user_with_permissions), 

12262# ) -> MetricsDict: 

12263# """ 

12264# Retrieve aggregate metrics for all entity types via the admin UI. 

12265 

12266# This endpoint collects and returns usage metrics for tools, resources, servers, 

12267# and prompts. The metrics are retrieved by calling the aggregate_metrics method 

12268# on each respective service, which compiles statistics about usage patterns, 

12269# success rates, and other relevant metrics for administrative monitoring 

12270# and analysis purposes. 

12271 

12272# Args: 

12273# db (Session): Database session dependency. 

12274# user (str): Authenticated user dependency. 

12275 

12276# Returns: 

12277# MetricsDict: A dictionary containing the aggregated metrics for tools, 

12278# resources, servers, and prompts. Each value is a Pydantic model instance 

12279# specific to the entity type. 

12280# """ 

12281# LOGGER.debug(f"User {get_user_email(user)} requested aggregate metrics") 

12282# tool_metrics = await tool_service.aggregate_metrics(db) 

12283# resource_metrics = await resource_service.aggregate_metrics(db) 

12284# server_metrics = await server_service.aggregate_metrics(db) 

12285# prompt_metrics = await prompt_service.aggregate_metrics(db) 

12286 

12287# # Return actual Pydantic model instances 

12288# return { 

12289# "tools": tool_metrics, 

12290# "resources": resource_metrics, 

12291# "servers": server_metrics, 

12292# "prompts": prompt_metrics, 

12293# } 

12294 

12295 

12296@admin_router.get("/metrics") 

12297@require_permission("admin.system_config", allow_admin_bypass=False) 

12298async def get_aggregated_metrics( 

12299 db: Session = Depends(get_db), 

12300 _user=Depends(get_current_user_with_permissions), 

12301) -> Dict[str, Any]: 

12302 """Retrieve aggregated metrics and top performers for all entity types. 

12303 

12304 This endpoint collects usage metrics and top-performing entities for tools, 

12305 resources, prompts, and servers by calling the respective service methods. 

12306 The results are compiled into a dictionary for administrative monitoring. 

12307 

12308 Args: 

12309 db (Session): Database session dependency for querying metrics. 

12310 

12311 Returns: 

12312 Dict[str, Any]: A dictionary containing aggregated metrics and top performers 

12313 for tools, resources, prompts, and servers. The structure includes: 

12314 - 'tools': Metrics for tools. 

12315 - 'resources': Metrics for resources. 

12316 - 'prompts': Metrics for prompts. 

12317 - 'servers': Metrics for servers. 

12318 - 'topPerformers': A nested dictionary with all tools, resources, prompts, 

12319 and servers with their metrics. 

12320 """ 

12321 metrics = { 

12322 "tools": await tool_service.aggregate_metrics(db), 

12323 "resources": await resource_service.aggregate_metrics(db), 

12324 "prompts": await prompt_service.aggregate_metrics(db), 

12325 "servers": await server_service.aggregate_metrics(db), 

12326 "topPerformers": { 

12327 "tools": await tool_service.get_top_tools(db, limit=10), 

12328 "resources": await resource_service.get_top_resources(db, limit=10), 

12329 "prompts": await prompt_service.get_top_prompts(db, limit=10), 

12330 "servers": await server_service.get_top_servers(db, limit=10), 

12331 }, 

12332 } 

12333 return metrics 

12334 

12335 

12336@admin_router.get("/metrics/partial", response_class=HTMLResponse) 

12337@require_permission("admin.system_config", allow_admin_bypass=False) 

12338async def admin_metrics_partial_html( 

12339 request: Request, 

12340 entity_type: str = Query("tools", description="Entity type: tools, resources, prompts, or servers"), 

12341 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

12342 per_page: int = Query(10, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

12343 db: Session = Depends(get_db), 

12344 user=Depends(get_current_user_with_permissions), 

12345): 

12346 """ 

12347 Return HTML partial for paginated top performers (HTMX endpoint). 

12348 

12349 Matches the /admin/tools/partial pattern for consistent pagination UX. 

12350 

12351 Args: 

12352 request: FastAPI request object 

12353 entity_type: Entity type (tools, resources, prompts, servers) 

12354 page: Page number (1-indexed) 

12355 per_page: Items per page 

12356 db: Database session 

12357 user: Authenticated user 

12358 

12359 Returns: 

12360 HTMLResponse with paginated table and OOB pagination controls 

12361 

12362 Raises: 

12363 HTTPException: If entity_type is not one of the valid types 

12364 """ 

12365 LOGGER.debug(f"User {get_user_email(user)} requested metrics partial (entity_type={entity_type}, page={page}, per_page={per_page})") 

12366 

12367 # Validate entity type 

12368 valid_types = ["tools", "resources", "prompts", "servers"] 

12369 if entity_type not in valid_types: 

12370 raise HTTPException(status_code=400, detail=f"Invalid entity_type. Must be one of: {', '.join(valid_types)}") 

12371 

12372 # Constrain parameters 

12373 page = max(1, page) 

12374 per_page = max(1, min(per_page, 1000)) 

12375 

12376 # Get all items for this entity type 

12377 if entity_type == "tools": 

12378 all_items = await tool_service.get_top_tools(db, limit=None) 

12379 elif entity_type == "resources": 

12380 all_items = await resource_service.get_top_resources(db, limit=None) 

12381 elif entity_type == "prompts": 

12382 all_items = await prompt_service.get_top_prompts(db, limit=None) 

12383 else: # servers 

12384 all_items = await server_service.get_top_servers(db, limit=None) 

12385 

12386 # Calculate pagination 

12387 total_items = len(all_items) 

12388 total_pages = math.ceil(total_items / per_page) if per_page > 0 else 0 

12389 offset = (page - 1) * per_page 

12390 paginated_items = all_items[offset : offset + per_page] 

12391 

12392 # Convert to JSON-serializable format 

12393 data = jsonable_encoder(paginated_items) 

12394 

12395 # Build pagination metadata 

12396 pagination = PaginationMeta( 

12397 page=page, 

12398 per_page=per_page, 

12399 total_items=total_items, 

12400 total_pages=total_pages, 

12401 has_next=page < total_pages, 

12402 has_prev=page > 1, 

12403 ) 

12404 

12405 # Render template 

12406 return request.app.state.templates.TemplateResponse( 

12407 request, 

12408 "metrics_top_performers_partial.html", 

12409 { 

12410 "request": request, 

12411 "entity_type": entity_type, 

12412 "data": data, 

12413 "pagination": pagination.model_dump(), 

12414 "root_path": request.scope.get("root_path", ""), 

12415 }, 

12416 ) 

12417 

12418 

12419@admin_router.post("/metrics/reset", response_model=Dict[str, object]) 

12420@require_permission("admin.system_config", allow_admin_bypass=False) 

12421async def admin_reset_metrics(db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, object]: 

12422 """ 

12423 Reset all metrics for tools, resources, servers, and prompts. 

12424 Each service must implement its own reset_metrics method. 

12425 

12426 Args: 

12427 db (Session): Database session dependency. 

12428 user (str): Authenticated user dependency. 

12429 

12430 Returns: 

12431 Dict[str, object]: A dictionary containing a success message and status. 

12432 

12433 Examples: 

12434 >>> callable(admin_reset_metrics) 

12435 True 

12436 >>> admin_reset_metrics.__name__ 

12437 'admin_reset_metrics' 

12438 """ 

12439 LOGGER.debug(f"User {get_user_email(user)} requested to reset all metrics") 

12440 await tool_service.reset_metrics(db) 

12441 await resource_service.reset_metrics(db) 

12442 await server_service.reset_metrics(db) 

12443 await prompt_service.reset_metrics(db) 

12444 return {"message": "All metrics reset successfully", "success": True} 

12445 

12446 

12447@admin_router.post("/gateways/test", response_model=GatewayTestResponse) 

12448@require_permission("gateways.read", allow_admin_bypass=False) 

12449async def admin_test_gateway( 

12450 request: GatewayTestRequest, team_id: Optional[str] = Depends(_validated_team_id_param), user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db) 

12451) -> GatewayTestResponse: 

12452 """ 

12453 Test a gateway by sending a request to its URL. 

12454 This endpoint allows administrators to test the connectivity and response 

12455 

12456 Args: 

12457 request (GatewayTestRequest): The request object containing the gateway URL and request details. 

12458 team_id (Optional[str]): Optional team ID for team-specific gateways. 

12459 user (str): Authenticated user dependency. 

12460 db (Session): Database session dependency. 

12461 

12462 Returns: 

12463 GatewayTestResponse: The response from the gateway, including status code, latency, and body 

12464 

12465 Examples: 

12466 >>> callable(admin_test_gateway) 

12467 True 

12468 >>> admin_test_gateway.__name__ 

12469 'admin_test_gateway' 

12470 """ 

12471 full_url = str(request.base_url).rstrip("/") + "/" + request.path.lstrip("/") 

12472 full_url = full_url.rstrip("/") 

12473 LOGGER.debug(f"User {get_user_email(user)} testing server at {request.base_url}.") 

12474 start_time: float = time.monotonic() 

12475 headers = request.headers or {} 

12476 

12477 # Attempt to find a registered gateway matching this URL and team 

12478 try: 

12479 gateway = gateway_service.get_first_gateway_by_url(db, str(request.base_url), team_id=team_id) 

12480 except Exception: 

12481 gateway = None 

12482 

12483 try: 

12484 user_email = get_user_email(user) 

12485 if gateway and gateway.auth_type == "oauth" and gateway.oauth_config: 

12486 grant_type = gateway.oauth_config.get("grant_type", "client_credentials") 

12487 

12488 if grant_type == "authorization_code": 

12489 # For Authorization Code flow, try to get stored tokens 

12490 try: 

12491 # First-Party 

12492 from mcpgateway.services.token_storage_service import TokenStorageService # pylint: disable=import-outside-toplevel 

12493 

12494 token_storage = TokenStorageService(db) 

12495 

12496 # Get user-specific OAuth token 

12497 if not user_email: 

12498 latency_ms = int((time.monotonic() - start_time) * 1000) 

12499 return GatewayTestResponse( 

12500 status_code=401, latency_ms=latency_ms, body={"error": f"User authentication required for OAuth-protected gateway '{gateway.name}'. Please ensure you are authenticated."} 

12501 ) 

12502 

12503 access_token: str = await token_storage.get_user_token(gateway.id, user_email) 

12504 

12505 if access_token: 

12506 headers["Authorization"] = f"Bearer {access_token}" 

12507 else: 

12508 latency_ms = int((time.monotonic() - start_time) * 1000) 

12509 return GatewayTestResponse( 

12510 status_code=401, latency_ms=latency_ms, body={"error": f"Please authorize {gateway.name} first. Visit /oauth/authorize/{gateway.id} to complete OAuth flow."} 

12511 ) 

12512 except Exception as e: 

12513 LOGGER.error(f"Failed to obtain stored OAuth token for gateway {gateway.name}: {e}") 

12514 latency_ms = int((time.monotonic() - start_time) * 1000) 

12515 return GatewayTestResponse(status_code=500, latency_ms=latency_ms, body={"error": f"OAuth token retrieval failed for gateway: {str(e)}"}) 

12516 else: 

12517 # For Client Credentials flow, get token directly 

12518 try: 

12519 oauth_manager = OAuthManager(request_timeout=int(os.getenv("OAUTH_REQUEST_TIMEOUT", "30")), max_retries=int(os.getenv("OAUTH_MAX_RETRIES", "3"))) 

12520 access_token: str = await oauth_manager.get_access_token(gateway.oauth_config) 

12521 headers["Authorization"] = f"Bearer {access_token}" 

12522 except Exception as e: 

12523 LOGGER.error(f"Failed to obtain OAuth access token for gateway {gateway.name}: {e}") 

12524 response_body = {"error": f"OAuth token retrieval failed for gateway: {str(e)}"} 

12525 else: 

12526 headers: dict = decode_auth(gateway.auth_value if gateway else None) 

12527 

12528 # Prepare request based on content type 

12529 content_type = getattr(request, "content_type", "application/json") 

12530 request_kwargs = {"method": request.method.upper(), "url": full_url, "headers": headers} 

12531 

12532 if request.body is not None: 

12533 if content_type == "application/x-www-form-urlencoded": 

12534 # Set proper content type header and use data parameter for form encoding 

12535 headers["Content-Type"] = "application/x-www-form-urlencoded" 

12536 request_kwargs["data"] = request.body 

12537 else: 

12538 # Default to JSON 

12539 headers["Content-Type"] = "application/json" 

12540 request_kwargs["json"] = request.body 

12541 

12542 async with ResilientHttpClient(client_args={"timeout": settings.federation_timeout, "verify": not settings.skip_ssl_verify}) as client: 

12543 response: httpx.Response = await client.request(**request_kwargs) 

12544 latency_ms = int((time.monotonic() - start_time) * 1000) 

12545 try: 

12546 response_body: Union[Dict[str, Any], str] = response.json() 

12547 except ValueError: 

12548 response_body = {"details": response.text} 

12549 

12550 # Structured logging: Log successful gateway test 

12551 structured_logger = get_structured_logger("gateway_service") 

12552 structured_logger.log( 

12553 level="INFO", 

12554 message=f"Gateway test completed: {request.base_url}", 

12555 event_type="gateway_tested", 

12556 component="gateway_service", 

12557 user_email=get_user_email(user), 

12558 team_id=team_id, 

12559 resource_type="gateway", 

12560 resource_id=gateway.id if gateway else None, 

12561 custom_fields={ 

12562 "gateway_name": gateway.name if gateway else None, 

12563 "gateway_url": str(request.base_url), 

12564 "test_method": request.method, 

12565 "test_path": request.path, 

12566 "status_code": response.status_code, 

12567 "latency_ms": latency_ms, 

12568 }, 

12569 ) 

12570 

12571 return GatewayTestResponse(status_code=response.status_code, latency_ms=latency_ms, body=response_body) 

12572 

12573 except httpx.RequestError as e: 

12574 LOGGER.warning(f"Gateway test failed: {e}") 

12575 latency_ms = int((time.monotonic() - start_time) * 1000) 

12576 

12577 # Structured logging: Log failed gateway test 

12578 structured_logger = get_structured_logger("gateway_service") 

12579 structured_logger.log( 

12580 level="ERROR", 

12581 message=f"Gateway test failed: {request.base_url}", 

12582 event_type="gateway_test_failed", 

12583 component="gateway_service", 

12584 user_email=get_user_email(user), 

12585 team_id=team_id, 

12586 resource_type="gateway", 

12587 resource_id=gateway.id if gateway else None, 

12588 error=e, 

12589 custom_fields={ 

12590 "gateway_name": gateway.name if gateway else None, 

12591 "gateway_url": str(request.base_url), 

12592 "test_method": request.method, 

12593 "test_path": request.path, 

12594 "latency_ms": latency_ms, 

12595 }, 

12596 ) 

12597 

12598 return GatewayTestResponse(status_code=502, latency_ms=latency_ms, body={"error": "Request failed", "details": str(e)}) 

12599 

12600 

12601# Event Streaming via SSE to the Admin UI 

12602@admin_router.get("/events") 

12603@require_permission("admin.events", allow_admin_bypass=False) 

12604async def admin_events(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)): 

12605 """ 

12606 Stream admin events from all services via SSE (Server-Sent Events). 

12607 

12608 This endpoint establishes a persistent connection to stream real-time updates 

12609 from the gateway service and tool service to the frontend. It aggregates 

12610 multiple event streams into a single asyncio queue for unified delivery. 

12611 

12612 Args: 

12613 request (Request): The FastAPI request object, used to detect client disconnection. 

12614 _user (Any): Authenticated user dependency (ensures admin permissions). 

12615 _db: Database session for permission checks. 

12616 

12617 Returns: 

12618 StreamingResponse: An async generator yielding SSE-formatted strings 

12619 (media_type="text/event-stream"). 

12620 

12621 Examples: 

12622 >>> # Test function exists and has correct name 

12623 >>> from mcpgateway.admin import admin_events 

12624 >>> admin_events.__name__ 

12625 'admin_events' 

12626 >>> # Test it's a coroutine function 

12627 >>> import inspect 

12628 >>> inspect.iscoroutinefunction(admin_events) 

12629 True 

12630 """ 

12631 # Create a shared queue to aggregate events from all services 

12632 event_queue = asyncio.Queue() 

12633 heartbeat_interval = 15.0 

12634 

12635 # Define a generic producer that feeds a specific stream into the queue 

12636 async def stream_to_queue(generator, source_name: str): 

12637 """Consume events from an async generator and forward them to a queue. 

12638 

12639 This coroutine iterates over an asynchronous generator and enqueues each 

12640 yielded event into a global or external `event_queue`. It gracefully 

12641 handles task cancellation and logs unexpected exceptions. 

12642 

12643 Args: 

12644 generator (AsyncGenerator): An asynchronous generator that yields events. 

12645 source_name (str): A human-readable label for the event source, used 

12646 for logging error messages. 

12647 

12648 Raises: 

12649 asyncio.CancelledError: If the task is cancelled externally. 

12650 Exception: Any unexpected exception raised while iterating over the 

12651 generator will be caught, logged, and suppressed. 

12652 

12653 Doctest: 

12654 >>> import asyncio 

12655 >>> class FakeQueue: 

12656 ... def __init__(self): 

12657 ... self.items = [] 

12658 ... async def put(self, item): 

12659 ... self.items.append(item) 

12660 ... 

12661 >>> async def fake_gen(): 

12662 ... yield 1 

12663 ... yield 2 

12664 ... yield 3 

12665 ... 

12666 >>> event_queue = FakeQueue() # monkey-patch the global name 

12667 >>> async def run_test(): 

12668 ... await stream_to_queue(fake_gen(), "test_source") 

12669 ... return event_queue.items 

12670 ... 

12671 >>> asyncio.run(run_test()) 

12672 [1, 2, 3] 

12673 

12674 """ 

12675 try: 

12676 async for event in generator: 

12677 await event_queue.put(event) 

12678 except Exception as e: 

12679 LOGGER.error(f"Error in {source_name} event subscription: {e}") 

12680 

12681 async def event_generator(): 

12682 """ 

12683 Asynchronous Server-Sent Events (SSE) generator. 

12684 

12685 This coroutine listens to multiple background event streams (e.g., from 

12686 gateway and tool services), funnels their events into a shared queue, and 

12687 yields them to the client in proper SSE format. 

12688 

12689 The function: 

12690 - Spawns background tasks to consume events from subscribed services. 

12691 - Monitors the client connection for disconnection. 

12692 - Yields SSE-formatted messages as they arrive. 

12693 - Cleans up subscription tasks on exit. 

12694 

12695 The SSE format emitted: 

12696 event: <event_type> 

12697 data: <json-encoded data> 

12698 

12699 Yields: 

12700 AsyncGenerator[str, None]: A generator yielding SSE-formatted strings. 

12701 

12702 Raises: 

12703 asyncio.CancelledError: If the SSE stream or background tasks are cancelled. 

12704 Exception: Any unexpected exception in the main loop is logged but not re-raised. 

12705 

12706 Notes: 

12707 This function expects the following names to exist in the outer scope: 

12708 - `request`: A FastAPI/Starlette Request object. 

12709 - `event_queue`: An asyncio.Queue instance where events are dispatched. 

12710 - `gateway_service` and `tool_service`: Services exposing async subscribe_events(). 

12711 - `stream_to_queue`: Coroutine to pipe service streams into the queue. 

12712 - `LOGGER`: Logger instance. 

12713 

12714 Example: 

12715 Basic doctest demonstrating SSE formatting from mock data: 

12716 

12717 >>> import orjson, asyncio 

12718 >>> class DummyRequest: 

12719 ... async def is_disconnected(self): 

12720 ... return False 

12721 >>> async def dummy_gen(): 

12722 ... # Simulate an event queue and minimal environment 

12723 ... global request, event_queue 

12724 ... request = DummyRequest() 

12725 ... event_queue = asyncio.Queue() 

12726 ... # Minimal stubs to satisfy references 

12727 ... class DummyService: 

12728 ... async def subscribe_events(self): 

12729 ... async def gen(): 

12730 ... yield {"type": "test", "data": {"a": 1}} 

12731 ... return gen() 

12732 ... global gateway_service, tool_service, stream_to_queue, LOGGER 

12733 ... gateway_service = tool_service = DummyService() 

12734 ... async def stream_to_queue(gen, tag): 

12735 ... async for e in gen: 

12736 ... await event_queue.put(e) 

12737 ... class DummyLogger: 

12738 ... def debug(self, *args, **kwargs): pass 

12739 ... def error(self, *args, **kwargs): pass 

12740 ... LOGGER = DummyLogger() 

12741 ... 

12742 ... agen = event_generator() 

12743 ... # Startup requires allowing tasks to enqueue 

12744 ... async def get_one(): 

12745 ... async for msg in agen: 

12746 ... return msg 

12747 ... return (await get_one()).startswith("event: test") 

12748 >>> asyncio.run(dummy_gen()) 

12749 True 

12750 """ 

12751 # Create background tasks for each service subscription 

12752 # This allows them to run concurrently 

12753 tasks = [asyncio.create_task(stream_to_queue(gateway_service.subscribe_events(), "gateway")), asyncio.create_task(stream_to_queue(tool_service.subscribe_events(), "tool"))] 

12754 

12755 try: 

12756 while True: 

12757 # Check for client disconnection 

12758 if await request.is_disconnected(): 

12759 LOGGER.debug("SSE Client disconnected") 

12760 break 

12761 

12762 # Wait for the next event from EITHER service 

12763 # We use asyncio.wait_for to allow checking request.is_disconnected periodically 

12764 # or simply rely on queue.get() which is efficient. 

12765 try: 

12766 # Wait for an event or send a keepalive to avoid idle timeouts 

12767 event = await asyncio.wait_for(event_queue.get(), timeout=heartbeat_interval) 

12768 

12769 # SSE format 

12770 event_type = event.get("type", "message") 

12771 event_data = orjson.dumps(event.get("data", {})).decode() 

12772 

12773 yield f"event: {event_type}\ndata: {event_data}\n\n" 

12774 

12775 # Mark task as done in queue (good practice) 

12776 event_queue.task_done() 

12777 except asyncio.TimeoutError: 

12778 yield ": keepalive\n\n" 

12779 

12780 except asyncio.CancelledError: 

12781 LOGGER.debug("SSE Event generator task cancelled") 

12782 raise 

12783 

12784 except asyncio.CancelledError: 

12785 LOGGER.debug("SSE Stream cancelled") 

12786 raise 

12787 except Exception as e: 

12788 LOGGER.error(f"SSE Stream error: {e}") 

12789 finally: 

12790 # Cleanup: Cancel all background subscription tasks 

12791 # This is crucial to close Redis connections/listeners in the EventService 

12792 for task in tasks: 

12793 task.cancel() 

12794 

12795 # Wait for tasks to clean up 

12796 await asyncio.gather(*tasks, return_exceptions=True) 

12797 LOGGER.debug("Background event subscription tasks cleaned up") 

12798 

12799 return StreamingResponse( 

12800 event_generator(), 

12801 media_type="text/event-stream", 

12802 headers={ 

12803 "Cache-Control": "no-cache", 

12804 "Connection": "keep-alive", 

12805 "X-Accel-Buffering": "no", 

12806 }, 

12807 ) 

12808 

12809 

12810#################### 

12811# Admin Tag Routes # 

12812#################### 

12813 

12814 

12815@admin_router.get("/tags", response_model=PaginatedResponse) 

12816@require_permission("tags.read", allow_admin_bypass=False) 

12817async def admin_list_tags( 

12818 entity_types: Optional[str] = None, 

12819 include_entities: bool = False, 

12820 db: Session = Depends(get_db), 

12821 user=Depends(get_current_user_with_permissions), 

12822) -> List[Dict[str, Any]]: 

12823 """ 

12824 List all unique tags with statistics for the admin UI. 

12825 

12826 Args: 

12827 entity_types: Comma-separated list of entity types to filter by 

12828 (e.g., "tools,resources,prompts,servers,gateways"). 

12829 If not provided, returns tags from all entity types. 

12830 include_entities: Whether to include the list of entities that have each tag 

12831 db: Database session 

12832 user: Authenticated user 

12833 

12834 Returns: 

12835 List of tag information with statistics 

12836 

12837 Raises: 

12838 HTTPException: If tag retrieval fails 

12839 

12840 Examples: 

12841 >>> # Test function exists and has correct name 

12842 >>> from mcpgateway.admin import admin_list_tags 

12843 >>> admin_list_tags.__name__ 

12844 'admin_list_tags' 

12845 >>> # Test it's a coroutine function 

12846 >>> import inspect 

12847 >>> inspect.iscoroutinefunction(admin_list_tags) 

12848 True 

12849 """ 

12850 tag_service = TagService() 

12851 

12852 # Parse entity types parameter if provided 

12853 entity_types_list = None 

12854 if entity_types: 

12855 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()] 

12856 

12857 LOGGER.debug(f"Admin user {user} is retrieving tags for entity types: {entity_types_list}, include_entities: {include_entities}") 

12858 

12859 try: 

12860 tags = await tag_service.get_all_tags(db, entity_types=entity_types_list, include_entities=include_entities) 

12861 

12862 # Convert to list of dicts for admin UI 

12863 result: List[Dict[str, Any]] = [] 

12864 for tag in tags: 

12865 tag_dict: Dict[str, Any] = { 

12866 "name": tag.name, 

12867 "tools": tag.stats.tools, 

12868 "resources": tag.stats.resources, 

12869 "prompts": tag.stats.prompts, 

12870 "servers": tag.stats.servers, 

12871 "gateways": tag.stats.gateways, 

12872 "total": tag.stats.total, 

12873 } 

12874 

12875 # Include entities if requested 

12876 if include_entities and tag.entities: 

12877 tag_dict["entities"] = [ 

12878 { 

12879 "id": entity.id, 

12880 "name": entity.name, 

12881 "type": entity.type, 

12882 "description": entity.description, 

12883 } 

12884 for entity in tag.entities 

12885 ] 

12886 

12887 result.append(tag_dict) 

12888 

12889 return result 

12890 except Exception as e: 

12891 LOGGER.error(f"Failed to retrieve tags for admin: {str(e)}") 

12892 raise HTTPException(status_code=500, detail=f"Failed to retrieve tags: {str(e)}") 

12893 

12894 

12895async def _read_request_json(request: Request) -> Any: 

12896 """Read JSON payload using orjson, falling back to request.json for mocks. 

12897 

12898 Args: 

12899 request: Incoming FastAPI request to read JSON from. 

12900 

12901 Returns: 

12902 Parsed JSON payload (dict/list/etc.). 

12903 """ 

12904 body = await request.body() 

12905 if isinstance(body, (bytes, bytearray, memoryview)): 

12906 if body: 

12907 return orjson.loads(body) 

12908 elif isinstance(body, str) and body: 

12909 return orjson.loads(body) 

12910 return await request.json() 

12911 

12912 

12913@admin_router.post("/tools/import/") 

12914@admin_router.post("/tools/import") 

12915@require_permission("tools.create", allow_admin_bypass=False) 

12916@rate_limit(requests_per_minute=settings.mcpgateway_bulk_import_rate_limit) 

12917async def admin_import_tools( 

12918 request: Request, 

12919 db: Session = Depends(get_db), 

12920 user=Depends(get_current_user_with_permissions), 

12921) -> JSONResponse: 

12922 """Bulk import multiple tools in a single request. 

12923 

12924 Accepts a JSON array of tool definitions and registers them individually. 

12925 Provides per-item validation and error reporting without failing the entire batch. 

12926 

12927 Args: 

12928 request: FastAPI Request containing the tools data 

12929 db: Database session 

12930 user: Authenticated username 

12931 

12932 Returns: 

12933 JSONResponse with success status, counts, and details of created/failed tools 

12934 

12935 Raises: 

12936 HTTPException: For authentication or rate limiting failures 

12937 """ 

12938 # Check if bulk import is enabled 

12939 if not settings.mcpgateway_bulk_import_enabled: 

12940 LOGGER.warning("Bulk import attempted but feature is disabled") 

12941 raise HTTPException(status_code=403, detail="Bulk import feature is disabled. Enable MCPGATEWAY_BULK_IMPORT_ENABLED to use this endpoint.") 

12942 

12943 LOGGER.debug("bulk tool import: user=%s", user) 

12944 try: 

12945 # ---------- robust payload parsing ---------- 

12946 ctype = (request.headers.get("content-type") or "").lower() 

12947 if "application/json" in ctype: 

12948 try: 

12949 payload = await _read_request_json(request) 

12950 except Exception as ex: 

12951 LOGGER.exception("Invalid JSON body") 

12952 return ORJSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422) 

12953 else: 

12954 try: 

12955 form = await request.form() 

12956 except Exception as ex: 

12957 LOGGER.exception("Invalid form body") 

12958 return ORJSONResponse({"success": False, "message": f"Invalid form data: {ex}"}, status_code=422) 

12959 # Check for file upload first 

12960 if "tools_file" in form: 

12961 file = form["tools_file"] 

12962 if isinstance(file, StarletteUploadFile): 

12963 content = await file.read() 

12964 try: 

12965 payload = orjson.loads(content.decode("utf-8")) 

12966 except (orjson.JSONDecodeError, UnicodeDecodeError) as ex: 

12967 LOGGER.exception("Invalid JSON file") 

12968 return ORJSONResponse({"success": False, "message": f"Invalid JSON file: {ex}"}, status_code=422) 

12969 else: 

12970 return ORJSONResponse({"success": False, "message": "Invalid file upload"}, status_code=422) 

12971 else: 

12972 # Check for JSON in form fields 

12973 raw_val = form.get("tools") or form.get("tools_json") or form.get("json") or form.get("payload") 

12974 raw = raw_val if isinstance(raw_val, str) else None 

12975 if not raw: 

12976 return ORJSONResponse({"success": False, "message": "Missing tools/tools_json/json/payload form field."}, status_code=422) 

12977 try: 

12978 payload = orjson.loads(raw) 

12979 except Exception as ex: 

12980 LOGGER.exception("Invalid JSON in form field") 

12981 return ORJSONResponse({"success": False, "message": f"Invalid JSON: {ex}"}, status_code=422) 

12982 

12983 if not isinstance(payload, list): 

12984 return ORJSONResponse({"success": False, "message": "Payload must be a JSON array of tools."}, status_code=422) 

12985 

12986 max_batch = settings.mcpgateway_bulk_import_max_tools 

12987 if len(payload) > max_batch: 

12988 return ORJSONResponse({"success": False, "message": f"Too many tools ({len(payload)}). Max {max_batch}."}, status_code=413) 

12989 

12990 created, errors = [], [] 

12991 

12992 # ---------- import loop ---------- 

12993 # Generate import batch ID for this bulk operation 

12994 import_batch_id = str(uuid.uuid4()) 

12995 

12996 # Extract base metadata for bulk import 

12997 base_metadata = MetadataCapture.extract_creation_metadata(request, user, import_batch_id=import_batch_id) 

12998 for i, item in enumerate(payload): 

12999 name = (item or {}).get("name") 

13000 try: 

13001 tool = ToolCreate(**item) # pydantic validation 

13002 await tool_service.register_tool( 

13003 db, 

13004 tool, 

13005 created_by=base_metadata["created_by"], 

13006 created_from_ip=base_metadata["created_from_ip"], 

13007 created_via="import", # Override to show this is bulk import 

13008 created_user_agent=base_metadata["created_user_agent"], 

13009 import_batch_id=import_batch_id, 

13010 federation_source=base_metadata["federation_source"], 

13011 ) 

13012 created.append({"index": i, "name": name}) 

13013 except IntegrityError as ex: 

13014 # The formatter can itself throw; guard it. 

13015 try: 

13016 formatted = ErrorFormatter.format_database_error(ex) 

13017 except Exception: 

13018 formatted = {"message": str(ex)} 

13019 errors.append({"index": i, "name": name, "error": formatted}) 

13020 except (ValidationError, CoreValidationError) as ex: 

13021 # Ditto: guard the formatter 

13022 try: 

13023 formatted = ErrorFormatter.format_validation_error(ex) 

13024 except Exception: 

13025 formatted = {"message": str(ex)} 

13026 errors.append({"index": i, "name": name, "error": formatted}) 

13027 except ToolError as ex: 

13028 errors.append({"index": i, "name": name, "error": {"message": str(ex)}}) 

13029 except Exception as ex: 

13030 LOGGER.exception("Unexpected error importing tool %r at index %d", name, i) 

13031 errors.append({"index": i, "name": name, "error": {"message": str(ex)}}) 

13032 

13033 # Format response to match both frontend and test expectations 

13034 response_data = { 

13035 "success": len(errors) == 0, 

13036 # New format for frontend 

13037 "imported": len(created), 

13038 "failed": len(errors), 

13039 "total": len(payload), 

13040 # Original format for tests 

13041 "created_count": len(created), 

13042 "failed_count": len(errors), 

13043 "created": created, 

13044 "errors": errors, 

13045 # Detailed format for frontend 

13046 "details": { 

13047 "success": [item["name"] for item in created if item.get("name")], 

13048 "failed": [{"name": item["name"], "error": item["error"].get("message", str(item["error"]))} for item in errors], 

13049 }, 

13050 } 

13051 

13052 rd = typing_cast(Dict[str, Any], response_data) 

13053 if len(errors) == 0: 

13054 rd["message"] = f"Successfully imported all {len(created)} tools" 

13055 else: 

13056 rd["message"] = f"Imported {len(created)} of {len(payload)} tools. {len(errors)} failed." 

13057 

13058 return ORJSONResponse( 

13059 response_data, 

13060 status_code=200, # Always return 200, success field indicates if all succeeded 

13061 ) 

13062 

13063 except HTTPException: 

13064 # let FastAPI semantics (e.g., auth) pass through 

13065 raise 

13066 except Exception as ex: 

13067 # absolute catch-all: report instead of crashing 

13068 LOGGER.exception("Fatal error in admin_import_tools") 

13069 return ORJSONResponse({"success": False, "message": str(ex)}, status_code=500) 

13070 

13071 

13072#################### 

13073# Log Endpoints 

13074#################### 

13075 

13076 

13077@admin_router.get("/logs") 

13078@require_permission("admin.system_config", allow_admin_bypass=False) 

13079async def admin_get_logs( 

13080 entity_type: Optional[str] = None, 

13081 entity_id: Optional[str] = None, 

13082 level: Optional[str] = None, 

13083 start_time: Optional[str] = None, 

13084 end_time: Optional[str] = None, 

13085 request_id: Optional[str] = None, 

13086 search: Optional[str] = None, 

13087 limit: int = 100, 

13088 offset: int = 0, 

13089 order: str = "desc", 

13090 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

13091 _db: Session = Depends(get_db), 

13092) -> Dict[str, Any]: 

13093 """Get filtered log entries from the in-memory buffer. 

13094 

13095 Args: 

13096 entity_type: Filter by entity type (tool, resource, server, gateway) 

13097 entity_id: Filter by entity ID 

13098 level: Minimum log level (debug, info, warning, error, critical) 

13099 start_time: ISO format start time 

13100 end_time: ISO format end time 

13101 request_id: Filter by request ID 

13102 search: Search in message text 

13103 limit: Maximum number of results (default 100, max 1000) 

13104 offset: Number of results to skip 

13105 order: Sort order (asc or desc) 

13106 user: Authenticated user 

13107 _db: Database session for permission checks. 

13108 

13109 Returns: 

13110 Dictionary with logs and metadata 

13111 

13112 Raises: 

13113 HTTPException: If validation fails or service unavailable 

13114 """ 

13115 # Get log storage from logging service 

13116 storage = typing_cast(Any, logging_service).get_storage() 

13117 if not storage: 

13118 return {"logs": [], "total": 0, "stats": {}} 

13119 

13120 # Parse timestamps if provided 

13121 start_dt = None 

13122 end_dt = None 

13123 if start_time: 

13124 try: 

13125 start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) 

13126 except ValueError: 

13127 raise HTTPException(400, f"Invalid start_time format: {start_time}") 

13128 

13129 if end_time: 

13130 try: 

13131 end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) 

13132 except ValueError: 

13133 raise HTTPException(400, f"Invalid end_time format: {end_time}") 

13134 

13135 # Parse log level 

13136 log_level = None 

13137 if level: 

13138 try: 

13139 log_level = LogLevel(level.lower()) 

13140 except ValueError: 

13141 raise HTTPException(400, f"Invalid log level: {level}") 

13142 

13143 # Limit max results 

13144 limit = min(limit, 1000) 

13145 

13146 # Get filtered logs 

13147 logs = await storage.get_logs( 

13148 entity_type=entity_type, 

13149 entity_id=entity_id, 

13150 level=log_level, 

13151 start_time=start_dt, 

13152 end_time=end_dt, 

13153 request_id=request_id, 

13154 search=search, 

13155 limit=limit, 

13156 offset=offset, 

13157 order=order, 

13158 ) 

13159 

13160 # Get statistics 

13161 stats = storage.get_stats() 

13162 

13163 return { 

13164 "logs": logs, 

13165 "total": stats.get("total_logs", 0), 

13166 "stats": stats, 

13167 } 

13168 

13169 

13170@admin_router.get("/logs/stream") 

13171@require_permission("admin.system_config", allow_admin_bypass=False) 

13172async def admin_stream_logs( 

13173 request: Request, 

13174 entity_type: Optional[str] = None, 

13175 entity_id: Optional[str] = None, 

13176 level: Optional[str] = None, 

13177 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

13178 _db: Session = Depends(get_db), 

13179): 

13180 """Stream real-time log updates via Server-Sent Events. 

13181 

13182 Args: 

13183 request: FastAPI request object 

13184 entity_type: Filter by entity type 

13185 entity_id: Filter by entity ID 

13186 level: Minimum log level 

13187 user: Authenticated user 

13188 _db: Database session for permission checks. 

13189 

13190 Returns: 

13191 SSE response with real-time log updates 

13192 

13193 Raises: 

13194 HTTPException: If log level is invalid or service unavailable 

13195 """ 

13196 # Get log storage from logging service 

13197 storage = typing_cast(Any, logging_service).get_storage() 

13198 if not storage: 

13199 raise HTTPException(503, "Log storage not available") 

13200 

13201 # Parse log level filter 

13202 min_level = None 

13203 if level: 

13204 try: 

13205 min_level = LogLevel(level.lower()) 

13206 except ValueError: 

13207 raise HTTPException(400, f"Invalid log level: {level}") 

13208 

13209 async def generate(): 

13210 """Generate SSE events for log streaming. 

13211 

13212 Yields: 

13213 Formatted SSE events containing log data 

13214 """ 

13215 try: 

13216 async for event in storage.subscribe(): 

13217 # Check if client disconnected 

13218 if await request.is_disconnected(): 

13219 break 

13220 

13221 # Apply filters 

13222 log_data = event.get("data", {}) 

13223 

13224 # Entity type filter 

13225 if entity_type and log_data.get("entity_type") != entity_type: 

13226 continue 

13227 

13228 # Entity ID filter 

13229 if entity_id and log_data.get("entity_id") != entity_id: 

13230 continue 

13231 

13232 # Level filter 

13233 if min_level: 

13234 log_level = log_data.get("level") 

13235 if log_level: 

13236 try: 

13237 if not storage._meets_level_threshold(LogLevel(log_level), min_level): # pylint: disable=protected-access 

13238 continue 

13239 except ValueError: 

13240 continue 

13241 

13242 # Send SSE event 

13243 yield f"data: {orjson.dumps(event).decode()}\n\n" 

13244 

13245 except Exception as e: 

13246 LOGGER.error(f"Error in log streaming: {e}") 

13247 yield f"event: error\ndata: {orjson.dumps({'error': str(e)}).decode()}\n\n" 

13248 

13249 return StreamingResponse( 

13250 generate(), 

13251 media_type="text/event-stream", 

13252 headers={ 

13253 "Cache-Control": "no-cache", 

13254 "X-Accel-Buffering": "no", # Disable Nginx buffering 

13255 }, 

13256 ) 

13257 

13258 

13259@admin_router.get("/logs/file") 

13260@require_permission("admin.system_config", allow_admin_bypass=False) 

13261async def admin_get_log_file( 

13262 filename: Optional[str] = None, 

13263 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

13264 _db: Session = Depends(get_db), 

13265): 

13266 """Download log file. 

13267 

13268 Args: 

13269 filename: Specific log file to download (optional) 

13270 user: Authenticated user 

13271 _db: Database session for permission checks. 

13272 

13273 Returns: 

13274 File download response or list of available files 

13275 

13276 Raises: 

13277 HTTPException: If file doesn't exist or access denied 

13278 """ 

13279 # Check if file logging is enabled 

13280 if not settings.log_to_file or not settings.log_file: 

13281 raise HTTPException(404, "File logging is not enabled") 

13282 

13283 # Determine log directory 

13284 log_dir = Path(settings.log_folder) if settings.log_folder else Path(".") 

13285 

13286 if filename: 

13287 # Download specific file 

13288 file_path = log_dir / filename 

13289 

13290 # Security: Ensure file is within log directory 

13291 try: 

13292 file_path = file_path.resolve() 

13293 log_dir_resolved = log_dir.resolve() 

13294 if not str(file_path).startswith(str(log_dir_resolved)): 

13295 raise HTTPException(403, "Access denied") 

13296 except Exception: 

13297 raise HTTPException(400, "Invalid file path") 

13298 

13299 # Check if file exists 

13300 if not file_path.exists() or not file_path.is_file(): 

13301 raise HTTPException(404, f"Log file not found: {filename}") 

13302 

13303 # Check if it's a log file 

13304 if not (file_path.suffix in [".log", ".jsonl", ".json"] or file_path.stem.startswith(Path(settings.log_file).stem)): 

13305 raise HTTPException(403, "Not a log file") 

13306 

13307 # Return file for download using FileResponse (streams asynchronously) 

13308 # Pre-stat the file to catch issues early and provide Content-Length 

13309 try: 

13310 file_stat = file_path.stat() 

13311 LOGGER.info(f"Serving log file download: {file_path.name} ({file_stat.st_size} bytes)") 

13312 return FileResponse( 

13313 path=file_path, 

13314 media_type="application/octet-stream", 

13315 filename=file_path.name, 

13316 stat_result=file_stat, 

13317 ) 

13318 except FileNotFoundError: 

13319 LOGGER.error(f"Log file disappeared before streaming: {filename}") 

13320 raise HTTPException(404, f"Log file not found: {filename}") 

13321 except Exception as e: 

13322 LOGGER.error(f"Error preparing file for download: {e}") 

13323 raise HTTPException(500, f"Error reading file for download: {e}") 

13324 

13325 # List available log files 

13326 log_files = [] 

13327 

13328 try: 

13329 # Main log file 

13330 main_log = log_dir / settings.log_file 

13331 if main_log.exists(): 

13332 stat = main_log.stat() 

13333 log_files.append( 

13334 { 

13335 "name": main_log.name, 

13336 "size": stat.st_size, 

13337 "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), 

13338 "type": "main", 

13339 } 

13340 ) 

13341 

13342 # Rotated log files 

13343 if settings.log_rotation_enabled: 

13344 pattern = f"{Path(settings.log_file).stem}.*" 

13345 for file in log_dir.glob(pattern): 

13346 if file.is_file() and file.name != main_log.name: # Exclude main log file 

13347 stat = file.stat() 

13348 log_files.append( 

13349 { 

13350 "name": file.name, 

13351 "size": stat.st_size, 

13352 "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), 

13353 "type": "rotated", 

13354 } 

13355 ) 

13356 

13357 # Storage log file (JSON lines) 

13358 storage_log = log_dir / f"{Path(settings.log_file).stem}_storage.jsonl" 

13359 if storage_log.exists(): 

13360 stat = storage_log.stat() 

13361 log_files.append( 

13362 { 

13363 "name": storage_log.name, 

13364 "size": stat.st_size, 

13365 "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), 

13366 "type": "storage", 

13367 } 

13368 ) 

13369 

13370 # Sort by modified time (newest first) 

13371 log_files.sort(key=lambda x: x["modified"], reverse=True) 

13372 

13373 except Exception as e: 

13374 LOGGER.error(f"Error listing log files: {e}") 

13375 raise HTTPException(500, f"Error listing log files: {e}") 

13376 

13377 return { 

13378 "log_directory": str(log_dir), 

13379 "files": log_files, 

13380 "total": len(log_files), 

13381 } 

13382 

13383 

13384@admin_router.get("/logs/export") 

13385@require_permission("admin.system_config", allow_admin_bypass=False) 

13386async def admin_export_logs( 

13387 export_format: str = Query("json", alias="format"), 

13388 entity_type: Optional[str] = None, 

13389 entity_id: Optional[str] = None, 

13390 level: Optional[str] = None, 

13391 start_time: Optional[str] = None, 

13392 end_time: Optional[str] = None, 

13393 request_id: Optional[str] = None, 

13394 search: Optional[str] = None, 

13395 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

13396 _db: Session = Depends(get_db), 

13397): 

13398 """Export filtered logs in JSON or CSV format. 

13399 

13400 Args: 

13401 export_format: Export format (json or csv) 

13402 entity_type: Filter by entity type 

13403 entity_id: Filter by entity ID 

13404 level: Minimum log level 

13405 start_time: ISO format start time 

13406 end_time: ISO format end time 

13407 request_id: Filter by request ID 

13408 search: Search in message text 

13409 user: Authenticated user 

13410 _db: Database session for permission checks. 

13411 

13412 Returns: 

13413 File download response with exported logs 

13414 

13415 Raises: 

13416 HTTPException: If validation fails or export format invalid 

13417 """ 

13418 # Standard 

13419 # Validate format 

13420 if export_format not in ["json", "csv"]: 

13421 raise HTTPException(400, f"Invalid format: {export_format}. Use 'json' or 'csv'") 

13422 

13423 # Get log storage from logging service 

13424 storage = typing_cast(Any, logging_service).get_storage() 

13425 if not storage: 

13426 raise HTTPException(503, "Log storage not available") 

13427 

13428 # Parse timestamps if provided 

13429 start_dt = None 

13430 end_dt = None 

13431 if start_time: 

13432 try: 

13433 start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00")) 

13434 except ValueError: 

13435 raise HTTPException(400, f"Invalid start_time format: {start_time}") 

13436 

13437 if end_time: 

13438 try: 

13439 end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00")) 

13440 except ValueError: 

13441 raise HTTPException(400, f"Invalid end_time format: {end_time}") 

13442 

13443 # Parse log level 

13444 log_level = None 

13445 if level: 

13446 try: 

13447 log_level = LogLevel(level.lower()) 

13448 except ValueError: 

13449 raise HTTPException(400, f"Invalid log level: {level}") 

13450 

13451 # Get all matching logs (no pagination for export) 

13452 logs = await storage.get_logs( 

13453 entity_type=entity_type, 

13454 entity_id=entity_id, 

13455 level=log_level, 

13456 start_time=start_dt, 

13457 end_time=end_dt, 

13458 request_id=request_id, 

13459 search=search, 

13460 limit=10000, # Reasonable max for export 

13461 offset=0, 

13462 order="desc", 

13463 ) 

13464 

13465 # Generate filename 

13466 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 

13467 filename = f"logs_export_{timestamp}.{export_format}" 

13468 

13469 if export_format == "json": 

13470 # Export as JSON 

13471 content = orjson.dumps(logs, default=str, option=orjson.OPT_INDENT_2).decode() 

13472 return Response( 

13473 content=content, 

13474 media_type="application/json", 

13475 headers={ 

13476 "Content-Disposition": f'attachment; filename="{filename}"', 

13477 }, 

13478 ) 

13479 

13480 # CSV format 

13481 # Create CSV content 

13482 output = io.StringIO() 

13483 

13484 if logs: 

13485 # Use first log to determine columns 

13486 fieldnames = [ 

13487 "timestamp", 

13488 "level", 

13489 "entity_type", 

13490 "entity_id", 

13491 "entity_name", 

13492 "message", 

13493 "logger", 

13494 "request_id", 

13495 ] 

13496 

13497 writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore") 

13498 writer.writeheader() 

13499 

13500 for log in logs: 

13501 # Flatten the log entry for CSV 

13502 row = {k: log.get(k, "") for k in fieldnames} 

13503 writer.writerow(row) 

13504 

13505 content = output.getvalue() 

13506 

13507 return Response( 

13508 content=content, 

13509 media_type="text/csv", 

13510 headers={ 

13511 "Content-Disposition": f'attachment; filename="{filename}"', 

13512 }, 

13513 ) 

13514 

13515 

13516@admin_router.get("/export/configuration") 

13517@require_permission("admin.system_config", allow_admin_bypass=False) 

13518async def admin_export_configuration( 

13519 request: Request, # pylint: disable=unused-argument 

13520 types: Optional[str] = None, 

13521 exclude_types: Optional[str] = None, 

13522 tags: Optional[str] = None, 

13523 include_inactive: bool = False, 

13524 include_dependencies: bool = True, 

13525 db: Session = Depends(get_db), 

13526 user=Depends(get_current_user_with_permissions), 

13527): 

13528 """ 

13529 Export gateway configuration via Admin UI. 

13530 

13531 Args: 

13532 request: FastAPI request object for extracting root path 

13533 types: Comma-separated entity types to include 

13534 exclude_types: Comma-separated entity types to exclude 

13535 tags: Comma-separated tags to filter by 

13536 include_inactive: Include inactive entities 

13537 include_dependencies: Include dependent entities 

13538 db: Database session 

13539 user: Authenticated user 

13540 

13541 Returns: 

13542 JSON file download with configuration export 

13543 

13544 Raises: 

13545 HTTPException: If export fails 

13546 """ 

13547 try: 

13548 LOGGER.info(f"Admin user {user} requested configuration export") 

13549 

13550 # Parse parameters 

13551 include_types = None 

13552 if types: 

13553 include_types = [t.strip() for t in types.split(",") if t.strip()] 

13554 

13555 exclude_types_list = None 

13556 if exclude_types: 

13557 exclude_types_list = [t.strip() for t in exclude_types.split(",") if t.strip()] 

13558 

13559 tags_list = None 

13560 if tags: 

13561 tags_list = [t.strip() for t in tags.split(",") if t.strip()] 

13562 

13563 # Extract username from user (which could be string or dict with token) 

13564 username = user if isinstance(user, str) else user.get("username", "unknown") 

13565 

13566 # Get root path for URL construction - prefer configured APP_ROOT_PATH 

13567 root_path = settings.app_root_path 

13568 

13569 # Perform export 

13570 export_data = await export_service.export_configuration( 

13571 db=db, 

13572 include_types=include_types, 

13573 exclude_types=exclude_types_list, 

13574 tags=tags_list, 

13575 include_inactive=include_inactive, 

13576 include_dependencies=include_dependencies, 

13577 exported_by=username, 

13578 root_path=root_path, 

13579 ) 

13580 

13581 # Generate filename 

13582 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

13583 filename = f"mcpgateway-config-export-{timestamp}.json" 

13584 

13585 # Return as downloadable file 

13586 content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode() 

13587 return Response( 

13588 content=content, 

13589 media_type="application/json", 

13590 headers={ 

13591 "Content-Disposition": f'attachment; filename="{filename}"', 

13592 }, 

13593 ) 

13594 

13595 except ExportError as e: 

13596 LOGGER.error(f"Admin export failed for user {user}: {str(e)}") 

13597 raise HTTPException(status_code=400, detail=str(e)) 

13598 except Exception as e: 

13599 LOGGER.error(f"Unexpected admin export error for user {user}: {str(e)}") 

13600 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") 

13601 

13602 

13603@admin_router.post("/export/selective") 

13604@require_permission("admin.system_config", allow_admin_bypass=False) 

13605async def admin_export_selective(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

13606 """ 

13607 Export selected entities via Admin UI with entity selection. 

13608 

13609 Args: 

13610 request: FastAPI request object 

13611 db: Database session 

13612 user: Authenticated user 

13613 

13614 Returns: 

13615 JSON file download with selective export data 

13616 

13617 Raises: 

13618 HTTPException: If export fails 

13619 

13620 Expects JSON body with entity selections: 

13621 { 

13622 "entity_selections": { 

13623 "tools": ["tool1", "tool2"], 

13624 "servers": ["server1"] 

13625 }, 

13626 "include_dependencies": true 

13627 } 

13628 """ 

13629 try: 

13630 LOGGER.info(f"Admin user {user} requested selective configuration export") 

13631 

13632 body = await _read_request_json(request) 

13633 entity_selections = body.get("entity_selections", {}) 

13634 include_dependencies = body.get("include_dependencies", True) 

13635 

13636 # Extract username from user (which could be string or dict with token) 

13637 username = user if isinstance(user, str) else user.get("username", "unknown") 

13638 

13639 # Get root path for URL construction - prefer configured APP_ROOT_PATH 

13640 root_path = settings.app_root_path 

13641 

13642 # Perform selective export 

13643 export_data = await export_service.export_selective(db=db, entity_selections=entity_selections, include_dependencies=include_dependencies, exported_by=username, root_path=root_path) 

13644 

13645 # Generate filename 

13646 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

13647 filename = f"mcpgateway-selective-export-{timestamp}.json" 

13648 

13649 # Return as downloadable file 

13650 content = orjson.dumps(export_data, option=orjson.OPT_INDENT_2).decode() 

13651 return Response( 

13652 content=content, 

13653 media_type="application/json", 

13654 headers={ 

13655 "Content-Disposition": f'attachment; filename="{filename}"', 

13656 }, 

13657 ) 

13658 

13659 except ExportError as e: 

13660 LOGGER.error(f"Admin selective export failed for user {user}: {str(e)}") 

13661 raise HTTPException(status_code=400, detail=str(e)) 

13662 except Exception as e: 

13663 LOGGER.error(f"Unexpected admin selective export error for user {user}: {str(e)}") 

13664 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}") 

13665 

13666 

13667@admin_router.post("/import/preview") 

13668@require_permission("admin.system_config", allow_admin_bypass=False) 

13669async def admin_import_preview(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

13670 """ 

13671 Preview import file to show available items for selective import. 

13672 

13673 Args: 

13674 request: FastAPI request object with import file data 

13675 db: Database session 

13676 user: Authenticated user 

13677 

13678 Returns: 

13679 JSON response with categorized import preview data 

13680 

13681 Raises: 

13682 HTTPException: 400 for invalid JSON or missing data field, validation errors; 

13683 500 for unexpected preview failures 

13684 

13685 Expects JSON body: 

13686 { 

13687 "data": { ... } // The import file content 

13688 } 

13689 """ 

13690 try: 

13691 LOGGER.info(f"Admin import preview requested by user: {user}") 

13692 

13693 # Parse request data 

13694 try: 

13695 data = await _read_request_json(request) 

13696 except ValueError as e: 

13697 raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") 

13698 

13699 # Extract import data 

13700 import_data = data.get("data") 

13701 if not import_data: 

13702 raise HTTPException(status_code=400, detail="Missing 'data' field with import content") 

13703 

13704 # Validate user permissions for import preview 

13705 username = user if isinstance(user, str) else user.get("username", "unknown") 

13706 LOGGER.info(f"Processing import preview for user: {username}") 

13707 

13708 # Generate preview 

13709 preview_data = await import_service.preview_import(db=db, import_data=import_data) 

13710 

13711 return ORJSONResponse(content={"success": True, "preview": preview_data, "message": f"Import preview generated. Found {preview_data['summary']['total_items']} total items."}) 

13712 

13713 except ImportValidationError as e: 

13714 LOGGER.error(f"Import validation failed for user {user}: {str(e)}") 

13715 raise HTTPException(status_code=400, detail=f"Invalid import data: {str(e)}") 

13716 except Exception as e: 

13717 LOGGER.error(f"Import preview failed for user {user}: {str(e)}") 

13718 raise HTTPException(status_code=500, detail=f"Preview failed: {str(e)}") 

13719 

13720 

13721@admin_router.post("/import/configuration") 

13722@require_permission("admin.system_config", allow_admin_bypass=False) 

13723async def admin_import_configuration(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)): 

13724 """ 

13725 Import configuration via Admin UI. 

13726 

13727 Args: 

13728 request: FastAPI request object 

13729 db: Database session 

13730 user: Authenticated user 

13731 

13732 Returns: 

13733 JSON response with import status 

13734 

13735 Raises: 

13736 HTTPException: If import fails 

13737 

13738 Expects JSON body with import data and options: 

13739 { 

13740 "import_data": { ... }, 

13741 "conflict_strategy": "update", 

13742 "dry_run": false, 

13743 "rekey_secret": "optional-new-secret", 

13744 "selected_entities": { ... } 

13745 } 

13746 """ 

13747 try: 

13748 LOGGER.info(f"Admin user {user} requested configuration import") 

13749 

13750 body = await _read_request_json(request) 

13751 import_data = body.get("import_data") 

13752 if not import_data: 

13753 raise HTTPException(status_code=400, detail="Missing import_data in request body") 

13754 

13755 conflict_strategy_str = body.get("conflict_strategy", "update") 

13756 dry_run = body.get("dry_run", False) 

13757 rekey_secret = body.get("rekey_secret") 

13758 selected_entities = body.get("selected_entities") 

13759 

13760 # Validate conflict strategy 

13761 try: 

13762 conflict_strategy = ConflictStrategy(conflict_strategy_str.lower()) 

13763 except ValueError: 

13764 allowed = [s.value for s in ConflictStrategy.__members__.values()] 

13765 raise HTTPException(status_code=400, detail=f"Invalid conflict strategy. Must be one of: {allowed}") 

13766 

13767 # Extract username from user (which could be string or dict with token) 

13768 username = user if isinstance(user, str) else user.get("username", "unknown") 

13769 

13770 # Perform import 

13771 status = await import_service.import_configuration( 

13772 db=db, import_data=import_data, conflict_strategy=conflict_strategy, dry_run=dry_run, rekey_secret=rekey_secret, imported_by=username, selected_entities=selected_entities 

13773 ) 

13774 

13775 return ORJSONResponse(content=status.to_dict()) 

13776 

13777 except ImportServiceError as e: 

13778 LOGGER.error(f"Admin import failed for user {user}: {str(e)}") 

13779 raise HTTPException(status_code=400, detail=str(e)) 

13780 except Exception as e: 

13781 LOGGER.error(f"Unexpected admin import error for user {user}: {str(e)}") 

13782 raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}") 

13783 

13784 

13785@admin_router.get("/import/status/{import_id}") 

13786@require_permission("admin.system_config", allow_admin_bypass=False) 

13787async def admin_get_import_status(import_id: str, user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)): 

13788 """Get import status via Admin UI. 

13789 

13790 Args: 

13791 import_id: Import operation ID 

13792 user: Authenticated user 

13793 _db: Database session for permission checks. 

13794 

13795 Returns: 

13796 JSON response with import status 

13797 

13798 Raises: 

13799 HTTPException: If import not found 

13800 """ 

13801 LOGGER.debug(f"Admin user {user} requested import status for {import_id}") 

13802 

13803 status = import_service.get_import_status(import_id) 

13804 if not status: 

13805 raise HTTPException(status_code=404, detail=f"Import {import_id} not found") 

13806 

13807 return ORJSONResponse(content=status.to_dict()) 

13808 

13809 

13810@admin_router.get("/import/status") 

13811@require_permission("admin.system_config", allow_admin_bypass=False) 

13812async def admin_list_import_statuses(user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)): 

13813 """List all import statuses via Admin UI. 

13814 

13815 Args: 

13816 user: Authenticated user 

13817 _db: Database session for permission checks. 

13818 

13819 Returns: 

13820 JSON response with list of import statuses 

13821 """ 

13822 LOGGER.debug(f"Admin user {user} requested all import statuses") 

13823 

13824 statuses = import_service.list_import_statuses() 

13825 return ORJSONResponse(content=[status.to_dict() for status in statuses]) 

13826 

13827 

13828# ============================================================================ # 

13829# A2A AGENT ADMIN ROUTES # 

13830# ============================================================================ # 

13831 

13832 

13833@admin_router.get("/a2a/{agent_id}", response_model=A2AAgentRead) 

13834@require_permission("a2a.read", allow_admin_bypass=False) 

13835async def admin_get_agent( 

13836 agent_id: str, 

13837 db: Session = Depends(get_db), 

13838 user=Depends(get_current_user_with_permissions), 

13839) -> Dict[str, Any]: 

13840 """Get A2A agent details for the admin UI. 

13841 

13842 Args: 

13843 agent_id: Agent ID. 

13844 db: Database session. 

13845 user: Authenticated user. 

13846 

13847 Returns: 

13848 Agent details. 

13849 

13850 Raises: 

13851 HTTPException: If the agent is not found. 

13852 Exception: For any other unexpected errors. 

13853 

13854 Examples: 

13855 >>> callable(admin_get_agent) 

13856 True 

13857 >>> admin_get_agent.__name__ 

13858 'admin_get_agent' 

13859 """ 

13860 LOGGER.debug(f"User {get_user_email(user)} requested details for agent ID {agent_id}") 

13861 try: 

13862 agent = await a2a_service.get_agent(db, agent_id) 

13863 return agent.model_dump(by_alias=True) 

13864 except A2AAgentNotFoundError as e: 

13865 raise HTTPException(status_code=404, detail=str(e)) 

13866 except Exception as e: 

13867 LOGGER.error(f"Error getting agent {agent_id}: {e}") 

13868 raise e 

13869 

13870 

13871@admin_router.get("/a2a", response_model=PaginatedResponse) 

13872@require_permission("a2a.read", allow_admin_bypass=False) 

13873async def admin_list_a2a_agents( 

13874 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

13875 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

13876 include_inactive: bool = False, 

13877 db: Session = Depends(get_db), 

13878 user=Depends(get_current_user_with_permissions), 

13879) -> Dict[str, Any]: 

13880 """ 

13881 List A2A Agents for the admin UI with pagination support. 

13882 

13883 This endpoint retrieves a paginated list of A2A (Agent-to-Agent) agents associated with 

13884 the current user. Administrators can optionally include inactive agents for 

13885 management or auditing purposes. Uses offset-based (page/per_page) pagination. 

13886 

13887 Args: 

13888 page (int): Page number (1-indexed) for offset pagination. 

13889 per_page (int): Number of items per page. 

13890 include_inactive (bool): Whether to include inactive agents in the results. 

13891 db (Session): Database session dependency. 

13892 user (dict): Authenticated user dependency. 

13893 

13894 Returns: 

13895 Dict[str, Any]: A dictionary containing: 

13896 - data: List of A2A agent records formatted with by_alias=True 

13897 - pagination: Pagination metadata 

13898 - links: Pagination links (optional) 

13899 

13900 Raises: 

13901 HTTPException (500): If an error occurs while retrieving the agent list. 

13902 

13903 Examples: 

13904 >>> callable(admin_list_a2a_agents) 

13905 True 

13906 >>> admin_list_a2a_agents.__name__ 

13907 'admin_list_a2a_agents' 

13908 """ 

13909 if a2a_service is None: 

13910 LOGGER.warning("A2A features are disabled, returning empty paginated response") 

13911 # First-Party 

13912 

13913 return { 

13914 "data": [], 

13915 "pagination": PaginationMeta(page=page, per_page=per_page, total_items=0, total_pages=0, has_next=False, has_prev=False).model_dump(), 

13916 "links": None, 

13917 } 

13918 

13919 LOGGER.debug(f"User {get_user_email(user)} requested A2A Agent list (page={page}, per_page={per_page})") 

13920 user_email = get_user_email(user) 

13921 

13922 # Call a2a_service.list_agents with page-based pagination 

13923 paginated_result = await a2a_service.list_agents( 

13924 db=db, 

13925 include_inactive=include_inactive, 

13926 page=page, 

13927 per_page=per_page, 

13928 user_email=user_email, 

13929 ) 

13930 

13931 # Return standardized paginated response 

13932 return { 

13933 "data": [agent.model_dump(by_alias=True) for agent in paginated_result["data"]], 

13934 "pagination": paginated_result["pagination"].model_dump(), 

13935 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

13936 } 

13937 

13938 

13939@admin_router.post("/a2a") 

13940@require_permission("a2a.create", allow_admin_bypass=False) 

13941async def admin_add_a2a_agent( 

13942 request: Request, 

13943 db: Session = Depends(get_db), 

13944 user=Depends(get_current_user_with_permissions), 

13945) -> JSONResponse: 

13946 """Add a new A2A agent via admin UI. 

13947 

13948 Args: 

13949 request: FastAPI request object 

13950 db: Database session 

13951 user: Authenticated user 

13952 

13953 Returns: 

13954 JSONResponse with success/error status 

13955 

13956 Raises: 

13957 HTTPException: If A2A features are disabled 

13958 """ 

13959 LOGGER.info(f"A2A agent creation request from user {user}") 

13960 

13961 if not a2a_service or not settings.mcpgateway_a2a_enabled: 

13962 LOGGER.warning("A2A agent creation attempted but A2A features are disabled") 

13963 return ORJSONResponse( 

13964 content={"message": "A2A features are disabled!", "success": False}, 

13965 status_code=403, 

13966 ) 

13967 

13968 form = await request.form() 

13969 try: 

13970 LOGGER.info(f"A2A agent creation form data: {dict(form)}") 

13971 

13972 user_email = get_user_email(user) 

13973 # Determine personal team for default assignment 

13974 team_id = form.get("team_id", None) 

13975 team_service = TeamManagementService(db) 

13976 team_id = await team_service.verify_team_for_user(user_email, team_id) 

13977 

13978 # Process tags 

13979 ts_val = form.get("tags", "") 

13980 tags_str = ts_val if isinstance(ts_val, str) else "" 

13981 tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()] if tags_str else [] 

13982 

13983 # Parse auth_headers JSON if present 

13984 auth_headers_json = str(form.get("auth_headers")) 

13985 auth_headers: list[dict[str, Any]] = [] 

13986 if auth_headers_json: 

13987 try: 

13988 auth_headers = orjson.loads(auth_headers_json) 

13989 except (orjson.JSONDecodeError, ValueError): 

13990 auth_headers = [] 

13991 

13992 # Parse OAuth configuration - support both JSON string and individual form fields 

13993 oauth_config_json = str(form.get("oauth_config")) 

13994 oauth_config: Optional[dict[str, Any]] = None 

13995 

13996 LOGGER.info(f"DEBUG: oauth_config_json from form = '{oauth_config_json}'") 

13997 LOGGER.info(f"DEBUG: Individual OAuth fields - grant_type='{form.get('oauth_grant_type')}', issuer='{form.get('oauth_issuer')}'") 

13998 

13999 # Option 1: Pre-assembled oauth_config JSON (from API calls) 

14000 if oauth_config_json and oauth_config_json != "None": 

14001 try: 

14002 oauth_config = orjson.loads(oauth_config_json) 

14003 # Encrypt the client secret if present 

14004 if oauth_config and "client_secret" in oauth_config: 

14005 encryption = get_encryption_service(settings.auth_encryption_secret) 

14006 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"]) 

14007 except (orjson.JSONDecodeError, ValueError) as e: 

14008 LOGGER.error(f"Failed to parse OAuth config: {e}") 

14009 oauth_config = None 

14010 

14011 # Option 2: Assemble from individual UI form fields 

14012 if not oauth_config: 

14013 oauth_grant_type = str(form.get("oauth_grant_type", "")) 

14014 oauth_issuer = str(form.get("oauth_issuer", "")) 

14015 oauth_token_url = str(form.get("oauth_token_url", "")) 

14016 oauth_authorization_url = str(form.get("oauth_authorization_url", "")) 

14017 oauth_redirect_uri = str(form.get("oauth_redirect_uri", "")) 

14018 oauth_client_id = str(form.get("oauth_client_id", "")) 

14019 oauth_client_secret = str(form.get("oauth_client_secret", "")) 

14020 oauth_username = str(form.get("oauth_username", "")) 

14021 oauth_password = str(form.get("oauth_password", "")) 

14022 oauth_scopes_str = str(form.get("oauth_scopes", "")) 

14023 

14024 # If any OAuth field is provided, assemble oauth_config 

14025 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]): 

14026 oauth_config = {} 

14027 

14028 if oauth_grant_type: 

14029 oauth_config["grant_type"] = oauth_grant_type 

14030 if oauth_issuer: 

14031 oauth_config["issuer"] = oauth_issuer 

14032 if oauth_token_url: 

14033 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint' 

14034 if oauth_authorization_url: 

14035 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint' 

14036 if oauth_redirect_uri: 

14037 oauth_config["redirect_uri"] = oauth_redirect_uri 

14038 if oauth_client_id: 

14039 oauth_config["client_id"] = oauth_client_id 

14040 if oauth_client_secret: 

14041 # Encrypt the client secret 

14042 encryption = get_encryption_service(settings.auth_encryption_secret) 

14043 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret) 

14044 

14045 # Add username and password for password grant type 

14046 if oauth_username: 

14047 oauth_config["username"] = oauth_username 

14048 if oauth_password: 

14049 oauth_config["password"] = oauth_password 

14050 

14051 # Parse scopes (comma or space separated) 

14052 if oauth_scopes_str: 

14053 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()] 

14054 if scopes: 

14055 oauth_config["scopes"] = scopes 

14056 

14057 LOGGER.info(f"✅ Assembled OAuth config from UI form fields: grant_type={oauth_grant_type}, issuer={oauth_issuer}") 

14058 LOGGER.info(f"DEBUG: Complete oauth_config = {oauth_config}") 

14059 

14060 passthrough_headers = str(form.get("passthrough_headers")) 

14061 if passthrough_headers and passthrough_headers.strip(): 

14062 try: 

14063 passthrough_headers = orjson.loads(passthrough_headers) 

14064 except (orjson.JSONDecodeError, ValueError): 

14065 # Fallback to comma-separated parsing 

14066 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()] 

14067 else: 

14068 passthrough_headers = None 

14069 

14070 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth" 

14071 auth_type_from_form = str(form.get("auth_type", "")) 

14072 LOGGER.info(f"DEBUG: auth_type from form: '{auth_type_from_form}', oauth_config present: {oauth_config is not None}") 

14073 if oauth_config and not auth_type_from_form: 

14074 auth_type_from_form = "oauth" 

14075 LOGGER.info("✅ Auto-detected OAuth configuration, setting auth_type='oauth'") 

14076 elif oauth_config and auth_type_from_form: 

14077 LOGGER.info(f"✅ OAuth config present with explicit auth_type='{auth_type_from_form}'") 

14078 

14079 agent_data = A2AAgentCreate( 

14080 name=form["name"], 

14081 description=form.get("description"), 

14082 endpoint_url=form["endpoint_url"], 

14083 agent_type=form.get("agent_type", "generic"), 

14084 auth_type=auth_type_from_form, 

14085 auth_username=str(form.get("auth_username", "")), 

14086 auth_password=str(form.get("auth_password", "")), 

14087 auth_token=str(form.get("auth_token", "")), 

14088 auth_header_key=str(form.get("auth_header_key", "")), 

14089 auth_header_value=str(form.get("auth_header_value", "")), 

14090 auth_headers=auth_headers if auth_headers else None, 

14091 oauth_config=oauth_config, 

14092 auth_value=form.get("auth_value") if form.get("auth_value") else None, 

14093 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None, 

14094 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None, 

14095 tags=tags, 

14096 visibility=form.get("visibility", "private"), 

14097 team_id=team_id, 

14098 owner_email=user_email, 

14099 passthrough_headers=passthrough_headers, 

14100 ) 

14101 

14102 LOGGER.info(f"Creating A2A agent: {agent_data.name} at {agent_data.endpoint_url}") 

14103 

14104 # Extract metadata from request 

14105 metadata = MetadataCapture.extract_creation_metadata(request, user) 

14106 

14107 await a2a_service.register_agent( 

14108 db, 

14109 agent_data, 

14110 created_by=metadata["created_by"], 

14111 created_from_ip=metadata["created_from_ip"], 

14112 created_via=metadata["created_via"], 

14113 created_user_agent=metadata["created_user_agent"], 

14114 import_batch_id=metadata["import_batch_id"], 

14115 federation_source=metadata["federation_source"], 

14116 team_id=team_id, 

14117 owner_email=user_email, 

14118 visibility=form.get("visibility", "private"), 

14119 ) 

14120 

14121 return ORJSONResponse( 

14122 content={"message": "A2A agent created successfully!", "success": True}, 

14123 status_code=200, 

14124 ) 

14125 

14126 except CoreValidationError as ex: 

14127 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=422) 

14128 except A2AAgentNameConflictError as ex: 

14129 LOGGER.error(f"A2A agent name conflict: {ex}") 

14130 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=409) 

14131 except A2AAgentError as ex: 

14132 LOGGER.error(f"A2A agent error: {ex}") 

14133 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

14134 except IntegrityError as ex: 

14135 return ORJSONResponse( 

14136 content=ErrorFormatter.format_database_error(ex), 

14137 status_code=409, 

14138 ) 

14139 except Exception as ex: 

14140 LOGGER.error(f"Error creating A2A agent: {ex}") 

14141 return ORJSONResponse(content={"message": str(ex), "success": False}, status_code=500) 

14142 

14143 

14144@admin_router.post("/a2a/{agent_id}/edit") 

14145@require_permission("a2a.update", allow_admin_bypass=False) 

14146async def admin_edit_a2a_agent( 

14147 agent_id: str, 

14148 request: Request, 

14149 db: Session = Depends(get_db), 

14150 user=Depends(get_current_user_with_permissions), 

14151) -> JSONResponse: 

14152 """ 

14153 Edit an existing A2A agent via the admin UI. 

14154 

14155 Expects form fields: 

14156 - name 

14157 - description (optional) 

14158 - endpoint_url 

14159 - agent_type 

14160 - tags (optional, comma-separated) 

14161 - auth_type (optional) 

14162 - auth_username (optional) 

14163 - auth_password (optional) 

14164 - auth_token (optional) 

14165 - auth_header_key / auth_header_value (optional) 

14166 - auth_headers (JSON array, optional) 

14167 - oauth_config (JSON string or individual OAuth fields) 

14168 - visibility (optional) 

14169 - team_id (optional) 

14170 - capabilities (JSON, optional) 

14171 - config (JSON, optional) 

14172 - passthrough_headers: Optional[List[str]] 

14173 

14174 Args: 

14175 agent_id (str): The ID of the agent being edited. 

14176 request (Request): The incoming FastAPI request containing form data. 

14177 db (Session): Active database session. 

14178 user: The authenticated admin user performing the edit. 

14179 

14180 Returns: 

14181 JSONResponse: A JSON response indicating success or failure. 

14182 

14183 Examples: 

14184 >>> callable(admin_edit_a2a_agent) 

14185 True 

14186 >>> admin_edit_a2a_agent.__name__ 

14187 'admin_edit_a2a_agent' 

14188 """ 

14189 

14190 try: 

14191 form = await request.form() 

14192 

14193 # Normalize tags 

14194 tags_raw = str(form.get("tags", "")) 

14195 tags = [t.strip() for t in tags_raw.split(",") if t.strip()] if tags_raw else [] 

14196 

14197 # Visibility 

14198 visibility = str(form.get("visibility", "private")) 

14199 

14200 # Agent Type 

14201 agent_type = str(form.get("agent_type", "generic")) 

14202 

14203 # Capabilities 

14204 raw_capabilities = form.get("capabilities") 

14205 capabilities = {} 

14206 if raw_capabilities: 

14207 try: 

14208 capabilities = orjson.loads(raw_capabilities) 

14209 except (ValueError, orjson.JSONDecodeError): 

14210 capabilities = {} 

14211 

14212 # Config 

14213 raw_config = form.get("config") 

14214 config = {} 

14215 if raw_config: 

14216 try: 

14217 config = orjson.loads(raw_config) 

14218 except (ValueError, orjson.JSONDecodeError): 

14219 config = {} 

14220 

14221 # Parse auth_headers JSON if present 

14222 auth_headers_json = str(form.get("auth_headers")) 

14223 auth_headers = [] 

14224 if auth_headers_json: 

14225 try: 

14226 auth_headers = orjson.loads(auth_headers_json) 

14227 except (orjson.JSONDecodeError, ValueError): 

14228 auth_headers = [] 

14229 

14230 # Passthrough headers 

14231 passthrough_headers = str(form.get("passthrough_headers")) 

14232 if passthrough_headers and passthrough_headers.strip(): 

14233 try: 

14234 passthrough_headers = orjson.loads(passthrough_headers) 

14235 except (orjson.JSONDecodeError, ValueError): 

14236 # Fallback to comma-separated parsing 

14237 passthrough_headers = [h.strip() for h in passthrough_headers.split(",") if h.strip()] 

14238 else: 

14239 passthrough_headers = None 

14240 

14241 # Parse OAuth configuration - support both JSON string and individual form fields 

14242 oauth_config_json = str(form.get("oauth_config")) 

14243 oauth_config: Optional[dict[str, Any]] = None 

14244 

14245 # Option 1: Pre-assembled oauth_config JSON (from API calls) 

14246 if oauth_config_json and oauth_config_json != "None": 

14247 try: 

14248 oauth_config = orjson.loads(oauth_config_json) 

14249 # Encrypt the client secret if present and not empty 

14250 if oauth_config and "client_secret" in oauth_config and oauth_config["client_secret"]: 

14251 encryption = get_encryption_service(settings.auth_encryption_secret) 

14252 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_config["client_secret"]) 

14253 except (orjson.JSONDecodeError, ValueError) as e: 

14254 LOGGER.error(f"Failed to parse OAuth config: {e}") 

14255 oauth_config = None 

14256 

14257 # Option 2: Assemble from individual UI form fields 

14258 if not oauth_config: 

14259 oauth_grant_type = str(form.get("oauth_grant_type", "")) 

14260 oauth_issuer = str(form.get("oauth_issuer", "")) 

14261 oauth_token_url = str(form.get("oauth_token_url", "")) 

14262 oauth_authorization_url = str(form.get("oauth_authorization_url", "")) 

14263 oauth_redirect_uri = str(form.get("oauth_redirect_uri", "")) 

14264 oauth_client_id = str(form.get("oauth_client_id", "")) 

14265 oauth_client_secret = str(form.get("oauth_client_secret", "")) 

14266 oauth_username = str(form.get("oauth_username", "")) 

14267 oauth_password = str(form.get("oauth_password", "")) 

14268 oauth_scopes_str = str(form.get("oauth_scopes", "")) 

14269 

14270 # If any OAuth field is provided, assemble oauth_config 

14271 if any([oauth_grant_type, oauth_issuer, oauth_token_url, oauth_authorization_url, oauth_client_id]): 

14272 oauth_config = {} 

14273 

14274 if oauth_grant_type: 

14275 oauth_config["grant_type"] = oauth_grant_type 

14276 if oauth_issuer: 

14277 oauth_config["issuer"] = oauth_issuer 

14278 if oauth_token_url: 

14279 oauth_config["token_url"] = oauth_token_url # OAuthManager expects 'token_url', not 'token_endpoint' 

14280 if oauth_authorization_url: 

14281 oauth_config["authorization_url"] = oauth_authorization_url # OAuthManager expects 'authorization_url', not 'authorization_endpoint' 

14282 if oauth_redirect_uri: 

14283 oauth_config["redirect_uri"] = oauth_redirect_uri 

14284 if oauth_client_id: 

14285 oauth_config["client_id"] = oauth_client_id 

14286 if oauth_client_secret: 

14287 # Encrypt the client secret 

14288 encryption = get_encryption_service(settings.auth_encryption_secret) 

14289 oauth_config["client_secret"] = await encryption.encrypt_secret_async(oauth_client_secret) 

14290 

14291 # Add username and password for password grant type 

14292 if oauth_username: 

14293 oauth_config["username"] = oauth_username 

14294 if oauth_password: 

14295 oauth_config["password"] = oauth_password 

14296 

14297 # Parse scopes (comma or space separated) 

14298 if oauth_scopes_str: 

14299 scopes = [s.strip() for s in oauth_scopes_str.replace(",", " ").split() if s.strip()] 

14300 if scopes: 

14301 oauth_config["scopes"] = scopes 

14302 

14303 LOGGER.info(f"✅ Assembled OAuth config from UI form fields (edit): grant_type={oauth_grant_type}, issuer={oauth_issuer}") 

14304 

14305 user_email = get_user_email(user) 

14306 team_service = TeamManagementService(db) 

14307 team_id = await team_service.verify_team_for_user(user_email, form.get("team_id")) 

14308 

14309 # Auto-detect OAuth: if oauth_config is present and auth_type not explicitly set, use "oauth" 

14310 auth_type_from_form = str(form.get("auth_type", "")) 

14311 if oauth_config and not auth_type_from_form: 

14312 auth_type_from_form = "oauth" 

14313 LOGGER.info("Auto-detected OAuth configuration in edit, setting auth_type='oauth'") 

14314 

14315 agent_update = A2AAgentUpdate( 

14316 name=form.get("name"), 

14317 description=form.get("description"), 

14318 endpoint_url=form.get("endpoint_url"), 

14319 agent_type=agent_type, 

14320 tags=tags, 

14321 auth_type=auth_type_from_form, 

14322 auth_username=str(form.get("auth_username", "")), 

14323 auth_password=str(form.get("auth_password", "")), 

14324 auth_token=str(form.get("auth_token", "")), 

14325 auth_header_key=str(form.get("auth_header_key", "")), 

14326 auth_header_value=str(form.get("auth_header_value", "")), 

14327 auth_value=str(form.get("auth_value", "")), 

14328 auth_query_param_key=str(form.get("auth_query_param_key", "")) or None, 

14329 auth_query_param_value=str(form.get("auth_query_param_value", "")) or None, 

14330 auth_headers=auth_headers if auth_headers else None, 

14331 passthrough_headers=passthrough_headers, 

14332 oauth_config=oauth_config, 

14333 visibility=visibility, 

14334 team_id=team_id, 

14335 owner_email=user_email, 

14336 capabilities=capabilities, # Optional, not editable via UI 

14337 config=config, # Optional, not editable via UI 

14338 ) 

14339 

14340 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) 

14341 await a2a_service.update_agent( 

14342 db=db, 

14343 agent_id=agent_id, 

14344 agent_data=agent_update, 

14345 modified_by=mod_metadata["modified_by"], 

14346 modified_from_ip=mod_metadata["modified_from_ip"], 

14347 modified_via=mod_metadata["modified_via"], 

14348 modified_user_agent=mod_metadata["modified_user_agent"], 

14349 ) 

14350 

14351 return ORJSONResponse({"message": "A2A agent updated successfully", "success": True}, status_code=200) 

14352 

14353 except ValidationError as ve: 

14354 return ORJSONResponse({"message": str(ve), "success": False}, status_code=422) 

14355 except IntegrityError as ie: 

14356 return ORJSONResponse({"message": str(ie), "success": False}, status_code=409) 

14357 except Exception as e: 

14358 return ORJSONResponse({"message": str(e), "success": False}, status_code=500) 

14359 

14360 

14361@admin_router.post("/a2a/{agent_id}/state") 

14362@require_permission("a2a.update", allow_admin_bypass=False) 

14363async def admin_set_a2a_agent_state( 

14364 agent_id: str, 

14365 request: Request, 

14366 db: Session = Depends(get_db), 

14367 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

14368) -> RedirectResponse: 

14369 """Toggle A2A agent status via admin UI. 

14370 

14371 Args: 

14372 agent_id: Agent ID 

14373 request: FastAPI request object 

14374 db: Database session 

14375 user: Authenticated user 

14376 

14377 Returns: 

14378 Redirect response to admin page with A2A tab 

14379 

14380 Raises: 

14381 HTTPException: If A2A features are disabled 

14382 """ 

14383 if not a2a_service or not settings.mcpgateway_a2a_enabled: 

14384 root_path = request.scope.get("root_path", "") 

14385 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303) 

14386 

14387 user_email = get_user_email(user) 

14388 error_message = None 

14389 try: 

14390 form = await request.form() 

14391 act_val = form.get("activate", "false") 

14392 activate = act_val.lower() == "true" if isinstance(act_val, str) else False 

14393 

14394 await a2a_service.set_agent_state(db, agent_id, activate, user_email=user_email) 

14395 

14396 except PermissionError as e: 

14397 LOGGER.warning(f"Permission denied for user {user_email} setting A2A agent state {agent_id}: {e}") 

14398 error_message = str(e) 

14399 except A2AAgentNotFoundError as e: 

14400 LOGGER.error(f"A2A agent state change failed - not found: {e}") 

14401 root_path = request.scope.get("root_path", "") 

14402 error_message = "A2A agent not found." 

14403 except Exception as e: 

14404 LOGGER.error(f"Error setting A2A agent state: {e}") 

14405 root_path = request.scope.get("root_path", "") 

14406 error_message = "Failed to set state of A2A agent. Please try again." 

14407 

14408 root_path = request.scope.get("root_path", "") 

14409 

14410 # Build redirect URL with error message if present 

14411 if error_message: 

14412 error_param = f"?error={urllib.parse.quote(error_message)}" 

14413 return RedirectResponse(f"{root_path}/admin/{error_param}#a2a-agents", status_code=303) 

14414 

14415 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303) 

14416 

14417 

14418@admin_router.post("/a2a/{agent_id}/delete") 

14419@require_permission("a2a.delete", allow_admin_bypass=False) 

14420async def admin_delete_a2a_agent( 

14421 agent_id: str, 

14422 request: Request, # pylint: disable=unused-argument 

14423 db: Session = Depends(get_db), 

14424 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

14425) -> RedirectResponse: 

14426 """Delete A2A agent via admin UI. 

14427 

14428 Args: 

14429 agent_id: Agent ID 

14430 request: FastAPI request object 

14431 db: Database session 

14432 user: Authenticated user 

14433 

14434 Returns: 

14435 Redirect response to admin page with A2A tab 

14436 

14437 Raises: 

14438 HTTPException: If A2A features are disabled 

14439 """ 

14440 if not a2a_service or not settings.mcpgateway_a2a_enabled: 

14441 root_path = request.scope.get("root_path", "") 

14442 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303) 

14443 

14444 form = await request.form() 

14445 purge_metrics = str(form.get("purge_metrics", "false")).lower() == "true" 

14446 error_message = None 

14447 try: 

14448 user_email = get_user_email(user) 

14449 await a2a_service.delete_agent(db, agent_id, user_email=user_email, purge_metrics=purge_metrics) 

14450 except PermissionError as e: 

14451 LOGGER.warning(f"Permission denied for user {get_user_email(user)} deleting A2A agent {agent_id}: {e}") 

14452 error_message = str(e) 

14453 except A2AAgentNotFoundError as e: 

14454 LOGGER.error(f"A2A agent delete failed - not found: {e}") 

14455 error_message = "A2A agent not found." 

14456 except Exception as e: 

14457 LOGGER.error(f"Error deleting A2A agent: {e}") 

14458 error_message = "Failed to delete A2A agent. Please try again." 

14459 

14460 root_path = request.scope.get("root_path", "") 

14461 

14462 # Build redirect URL with error message if present 

14463 if error_message: 

14464 error_param = f"?error={urllib.parse.quote(error_message)}" 

14465 return RedirectResponse(f"{root_path}/admin/{error_param}#a2a-agents", status_code=303) 

14466 

14467 return RedirectResponse(f"{root_path}/admin#a2a-agents", status_code=303) 

14468 

14469 

14470@admin_router.post("/a2a/{agent_id}/test") 

14471@require_permission("a2a.invoke", allow_admin_bypass=False) 

14472async def admin_test_a2a_agent( 

14473 agent_id: str, 

14474 request: Request, 

14475 db: Session = Depends(get_db), 

14476 user=Depends(get_current_user_with_permissions), 

14477) -> JSONResponse: 

14478 """Test A2A agent via admin UI. 

14479 

14480 Args: 

14481 agent_id: Agent ID 

14482 request: FastAPI request object containing optional 'query' field 

14483 db: Database session 

14484 user: Authenticated user 

14485 

14486 Returns: 

14487 JSON response with test results 

14488 

14489 Raises: 

14490 HTTPException: If A2A features are disabled 

14491 """ 

14492 if not a2a_service or not settings.mcpgateway_a2a_enabled: 

14493 return ORJSONResponse(content={"success": False, "error": "A2A features are disabled"}, status_code=403) 

14494 

14495 try: 

14496 user_email = get_user_email(user) 

14497 # Get the agent by ID 

14498 agent = await a2a_service.get_agent(db, agent_id) 

14499 

14500 # Parse request body to get user-provided query 

14501 default_message = "Hello from MCP Gateway Admin UI test!" 

14502 try: 

14503 body = await _read_request_json(request) 

14504 # Use 'or' to also handle empty string queries 

14505 user_query = (body.get("query") if body else None) or default_message 

14506 except Exception: 

14507 user_query = default_message 

14508 

14509 # Prepare test parameters based on agent type and endpoint 

14510 if agent.agent_type in ["generic", "jsonrpc"] or agent.endpoint_url.endswith("/"): 

14511 # JSONRPC format for agents that expect it 

14512 test_params = { 

14513 "method": "message/send", 

14514 # A2A v0.3.x: message.parts use "kind" (not "type"). 

14515 "params": { 

14516 "message": { 

14517 "kind": "message", 

14518 "messageId": f"admin-test-{int(time.time())}", 

14519 "role": "user", 

14520 "parts": [{"kind": "text", "text": user_query}], 

14521 } 

14522 }, 

14523 } 

14524 else: 

14525 # Generic test format 

14526 test_params = {"query": user_query, "message": user_query, "test": True, "timestamp": int(time.time())} 

14527 

14528 # Invoke the agent 

14529 result = await a2a_service.invoke_agent( 

14530 db, 

14531 agent.name, 

14532 test_params, 

14533 "admin_test", 

14534 user_email=user_email, 

14535 user_id=user_email, 

14536 ) 

14537 

14538 return ORJSONResponse(content={"success": True, "result": result, "agent_name": agent.name, "test_timestamp": time.time()}) 

14539 

14540 except Exception as e: 

14541 LOGGER.error(f"Error testing A2A agent {agent_id}: {e}") 

14542 return ORJSONResponse(content={"success": False, "error": str(e), "agent_id": agent_id}, status_code=500) 

14543 

14544 

14545# gRPC Service Management Endpoints 

14546 

14547 

14548@admin_router.get("/grpc", response_model=PaginatedResponse) 

14549@require_permission("admin.grpc", allow_admin_bypass=False) 

14550async def admin_list_grpc_services( 

14551 page: int = Query(1, ge=1, description="Page number (1-indexed)"), 

14552 per_page: int = Query(settings.pagination_default_page_size, ge=1, le=settings.pagination_max_page_size, description="Items per page"), 

14553 include_inactive: bool = False, 

14554 team_id: Optional[str] = Depends(_validated_team_id_param), 

14555 db: Session = Depends(get_db), 

14556 user=Depends(get_current_user_with_permissions), 

14557) -> Dict[str, Any]: 

14558 """List all gRPC services for the admin UI with pagination support. 

14559 

14560 This endpoint retrieves a paginated list of gRPC services. Administrators can 

14561 optionally include inactive services for management or auditing purposes. 

14562 Uses offset-based (page/per_page) pagination. 

14563 

14564 Args: 

14565 page: Page number (1-indexed) for offset pagination 

14566 per_page: Number of items per page 

14567 include_inactive: Whether to include inactive services in the results 

14568 team_id: Optional team ID to filter by specific team 

14569 db: Database session dependency 

14570 user: Authenticated user dependency 

14571 

14572 Returns: 

14573 Dict[str, Any]: A dictionary containing: 

14574 - data: List of gRPC service records formatted with by_alias=True 

14575 - pagination: Pagination metadata 

14576 - links: Pagination links (optional) 

14577 

14578 Raises: 

14579 HTTPException: If gRPC support is disabled or not available 

14580 """ 

14581 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14582 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14583 

14584 user_email = get_user_email(user) 

14585 

14586 # Call grpc_service_mgr.list_services with page-based pagination 

14587 paginated_result = await grpc_service_mgr.list_services( 

14588 db=db, 

14589 include_inactive=include_inactive, 

14590 page=page, 

14591 per_page=per_page, 

14592 user_email=user_email, 

14593 team_id=team_id, 

14594 ) 

14595 

14596 # Return standardized paginated response 

14597 return { 

14598 "data": [service.model_dump(by_alias=True) for service in paginated_result["data"]], 

14599 "pagination": paginated_result["pagination"].model_dump(), 

14600 "links": paginated_result["links"].model_dump() if paginated_result["links"] else None, 

14601 } 

14602 

14603 

14604@admin_router.post("/grpc") 

14605@require_permission("admin.grpc", allow_admin_bypass=False) 

14606async def admin_create_grpc_service( 

14607 service: GrpcServiceCreate, 

14608 request: Request, 

14609 db: Session = Depends(get_db), 

14610 user=Depends(get_current_user_with_permissions), 

14611): 

14612 """Create a new gRPC service. 

14613 

14614 Args: 

14615 service: gRPC service creation data 

14616 request: FastAPI request object 

14617 db: Database session 

14618 user: Authenticated user 

14619 

14620 Returns: 

14621 Created gRPC service 

14622 

14623 Raises: 

14624 HTTPException: If gRPC support is disabled or creation fails 

14625 """ 

14626 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14627 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14628 

14629 try: 

14630 metadata = MetadataCapture.extract_creation_metadata(request, user) 

14631 user_email = get_user_email(user) 

14632 result = await grpc_service_mgr.register_service(db, service, user_email, metadata) 

14633 return ORJSONResponse(content=jsonable_encoder(result), status_code=201) 

14634 except GrpcServiceNameConflictError as e: 

14635 raise HTTPException(status_code=409, detail=str(e)) 

14636 except GrpcServiceError as e: 

14637 LOGGER.error(f"gRPC service error: {e}") 

14638 raise HTTPException(status_code=500, detail=str(e)) 

14639 

14640 

14641@admin_router.get("/grpc/{service_id}", response_model=GrpcServiceRead) 

14642@require_permission("admin.grpc", allow_admin_bypass=False) 

14643async def admin_get_grpc_service( 

14644 service_id: str, 

14645 db: Session = Depends(get_db), 

14646 user=Depends(get_current_user_with_permissions), 

14647): 

14648 """Get a specific gRPC service. 

14649 

14650 Args: 

14651 service_id: Service ID 

14652 db: Database session 

14653 user: Authenticated user 

14654 

14655 Returns: 

14656 The gRPC service 

14657 

14658 Raises: 

14659 HTTPException: If gRPC support is disabled or service not found 

14660 """ 

14661 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14662 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14663 

14664 try: 

14665 user_email = get_user_email(user) 

14666 return await grpc_service_mgr.get_service(db, service_id, user_email) 

14667 except GrpcServiceNotFoundError as e: 

14668 raise HTTPException(status_code=404, detail=str(e)) 

14669 

14670 

14671@admin_router.put("/grpc/{service_id}") 

14672@require_permission("admin.grpc", allow_admin_bypass=False) 

14673async def admin_update_grpc_service( 

14674 service_id: str, 

14675 service: GrpcServiceUpdate, 

14676 request: Request, 

14677 db: Session = Depends(get_db), 

14678 user=Depends(get_current_user_with_permissions), 

14679): 

14680 """Update a gRPC service. 

14681 

14682 Args: 

14683 service_id: Service ID 

14684 service: Update data 

14685 request: FastAPI request object 

14686 db: Database session 

14687 user: Authenticated user 

14688 

14689 Returns: 

14690 Updated gRPC service 

14691 

14692 Raises: 

14693 HTTPException: If gRPC support is disabled or update fails 

14694 """ 

14695 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14696 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14697 

14698 try: 

14699 metadata = MetadataCapture.extract_modification_metadata(request, user, 0) 

14700 user_email = get_user_email(user) 

14701 result = await grpc_service_mgr.update_service(db, service_id, service, user_email, metadata) 

14702 return ORJSONResponse(content=jsonable_encoder(result)) 

14703 except GrpcServiceNotFoundError as e: 

14704 raise HTTPException(status_code=404, detail=str(e)) 

14705 except GrpcServiceNameConflictError as e: 

14706 raise HTTPException(status_code=409, detail=str(e)) 

14707 except GrpcServiceError as e: 

14708 LOGGER.error(f"gRPC service error: {e}") 

14709 raise HTTPException(status_code=500, detail=str(e)) 

14710 

14711 

14712@admin_router.post("/grpc/{service_id}/state") 

14713@require_permission("admin.grpc", allow_admin_bypass=False) 

14714async def admin_set_grpc_service_state( 

14715 service_id: str, 

14716 activate: Optional[bool] = Query(None, description="Set enabled state. If not provided, inverts current state."), 

14717 db: Session = Depends(get_db), 

14718 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

14719): 

14720 """Set a gRPC service's enabled state. 

14721 

14722 Args: 

14723 service_id: Service ID 

14724 activate: If provided, sets enabled to this value. If None, inverts current state (legacy behavior). 

14725 db: Database session 

14726 user: Authenticated user 

14727 

14728 Returns: 

14729 Updated gRPC service 

14730 

14731 Raises: 

14732 HTTPException: If gRPC support is disabled or state change fails 

14733 """ 

14734 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14735 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14736 

14737 try: 

14738 if activate is None: 

14739 # Legacy toggle behavior - invert current state 

14740 service = await grpc_service_mgr.get_service(db, service_id) 

14741 activate = not service.enabled 

14742 result = await grpc_service_mgr.set_service_state(db, service_id, activate) 

14743 return ORJSONResponse(content=jsonable_encoder(result)) 

14744 except GrpcServiceNotFoundError as e: 

14745 raise HTTPException(status_code=404, detail=str(e)) 

14746 

14747 

14748@admin_router.post("/grpc/{service_id}/delete") 

14749@require_permission("admin.grpc", allow_admin_bypass=False) 

14750async def admin_delete_grpc_service( 

14751 service_id: str, 

14752 db: Session = Depends(get_db), 

14753 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

14754): 

14755 """Delete a gRPC service. 

14756 

14757 Args: 

14758 service_id: Service ID 

14759 db: Database session 

14760 user: Authenticated user 

14761 

14762 Returns: 

14763 No content response 

14764 

14765 Raises: 

14766 HTTPException: If gRPC support is disabled or deletion fails 

14767 """ 

14768 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14769 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14770 

14771 try: 

14772 await grpc_service_mgr.delete_service(db, service_id) 

14773 return Response(status_code=204) 

14774 except GrpcServiceNotFoundError as e: 

14775 raise HTTPException(status_code=404, detail=str(e)) 

14776 

14777 

14778@admin_router.post("/grpc/{service_id}/reflect") 

14779@require_permission("admin.grpc", allow_admin_bypass=False) 

14780async def admin_reflect_grpc_service( 

14781 service_id: str, 

14782 db: Session = Depends(get_db), 

14783 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

14784): 

14785 """Trigger re-reflection on a gRPC service. 

14786 

14787 Args: 

14788 service_id: Service ID 

14789 db: Database session 

14790 user: Authenticated user 

14791 

14792 Returns: 

14793 Updated gRPC service with reflection results 

14794 

14795 Raises: 

14796 HTTPException: If gRPC support is disabled or reflection fails 

14797 """ 

14798 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14799 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14800 

14801 try: 

14802 result = await grpc_service_mgr.reflect_service(db, service_id) 

14803 return ORJSONResponse(content=jsonable_encoder(result)) 

14804 except GrpcServiceNotFoundError as e: 

14805 raise HTTPException(status_code=404, detail=str(e)) 

14806 except GrpcServiceError as e: 

14807 LOGGER.error(f"gRPC service error: {e}") 

14808 raise HTTPException(status_code=500, detail=str(e)) 

14809 

14810 

14811@admin_router.get("/grpc/{service_id}/methods") 

14812@require_permission("admin.grpc", allow_admin_bypass=False) 

14813async def admin_get_grpc_methods( 

14814 service_id: str, 

14815 db: Session = Depends(get_db), 

14816 user=Depends(get_current_user_with_permissions), # pylint: disable=unused-argument 

14817): 

14818 """Get methods for a gRPC service. 

14819 

14820 Args: 

14821 service_id: Service ID 

14822 db: Database session 

14823 user: Authenticated user 

14824 

14825 Returns: 

14826 List of gRPC methods 

14827 

14828 Raises: 

14829 HTTPException: If gRPC support is disabled or service not found 

14830 """ 

14831 if not GRPC_AVAILABLE or not settings.mcpgateway_grpc_enabled: 

14832 raise HTTPException(status_code=404, detail="gRPC support is not available or disabled") 

14833 

14834 try: 

14835 methods = await grpc_service_mgr.get_service_methods(db, service_id) 

14836 return ORJSONResponse(content={"methods": methods}) 

14837 except GrpcServiceNotFoundError as e: 

14838 raise HTTPException(status_code=404, detail=str(e)) 

14839 

14840 

14841@admin_router.get("/sections/resources") 

14842@require_permission("resources.read", allow_admin_bypass=False) 

14843async def get_resources_section( 

14844 team_id: Optional[str] = None, 

14845 db: Session = Depends(get_db), 

14846 user=Depends(get_current_user_with_permissions), 

14847): 

14848 """Get resources data filtered by team. 

14849 

14850 Args: 

14851 team_id: Optional team ID to filter by 

14852 db: Database session 

14853 user: Current authenticated user context 

14854 

14855 Returns: 

14856 JSONResponse: Resources data with team filtering applied 

14857 """ 

14858 try: 

14859 local_resource_service = ResourceService() 

14860 user_email = get_user_email(user) 

14861 LOGGER.debug(f"User {user_email} requesting resources section with team_id={team_id}") 

14862 

14863 # Get all resources and filter by team 

14864 resources_list = await local_resource_service.list_resources(db, include_inactive=True) 

14865 

14866 # Apply team filtering if specified 

14867 if team_id: 

14868 resources_list = [r for r in resources_list if getattr(r, "team_id", None) == team_id] 

14869 

14870 # Convert to JSON-serializable format 

14871 resources = [] 

14872 for resource in resources_list: 

14873 resource_dict = ( 

14874 resource.model_dump(by_alias=True) 

14875 if hasattr(resource, "model_dump") 

14876 else { 

14877 "id": resource.id, 

14878 "name": resource.name, 

14879 "description": resource.description, 

14880 "uri": resource.uri, 

14881 "tags": resource.tags or [], 

14882 "isActive": resource.enabled, 

14883 "team_id": getattr(resource, "team_id", None), 

14884 "visibility": getattr(resource, "visibility", "private"), 

14885 } 

14886 ) 

14887 resources.append(resource_dict) 

14888 

14889 return ORJSONResponse(content={"resources": resources, "team_id": team_id}) 

14890 

14891 except Exception as e: 

14892 LOGGER.error(f"Error loading resources section: {e}") 

14893 return ORJSONResponse(content={"error": str(e)}, status_code=500) 

14894 

14895 

14896@admin_router.get("/sections/prompts") 

14897@require_permission("prompts.read", allow_admin_bypass=False) 

14898async def get_prompts_section( 

14899 team_id: Optional[str] = None, 

14900 db: Session = Depends(get_db), 

14901 user=Depends(get_current_user_with_permissions), 

14902): 

14903 """Get prompts data filtered by team. 

14904 

14905 Args: 

14906 team_id: Optional team ID to filter by 

14907 db: Database session 

14908 user: Current authenticated user context 

14909 

14910 Returns: 

14911 JSONResponse: Prompts data with team filtering applied 

14912 """ 

14913 try: 

14914 local_prompt_service = PromptService() 

14915 user_email = get_user_email(user) 

14916 LOGGER.debug(f"User {user_email} requesting prompts section with team_id={team_id}") 

14917 

14918 # Get all prompts and filter by team 

14919 prompts_list = await local_prompt_service.list_prompts(db, include_inactive=True) 

14920 

14921 # Apply team filtering if specified 

14922 if team_id: 

14923 prompts_list = [p for p in prompts_list if getattr(p, "team_id", None) == team_id] 

14924 

14925 # Convert to JSON-serializable format 

14926 prompts = [] 

14927 for prompt in prompts_list: 

14928 prompt_dict = ( 

14929 prompt.model_dump(by_alias=True) 

14930 if hasattr(prompt, "model_dump") 

14931 else { 

14932 "id": prompt.id, 

14933 "name": prompt.name, 

14934 "description": prompt.description, 

14935 "arguments": prompt.arguments or [], 

14936 "tags": prompt.tags or [], 

14937 # Prompt enabled/disabled state is stored on the prompt as `enabled`. 

14938 "isActive": getattr(prompt, "enabled", False), 

14939 "team_id": getattr(prompt, "team_id", None), 

14940 "visibility": getattr(prompt, "visibility", "private"), 

14941 } 

14942 ) 

14943 prompts.append(prompt_dict) 

14944 

14945 return ORJSONResponse(content={"prompts": prompts, "team_id": team_id}) 

14946 

14947 except Exception as e: 

14948 LOGGER.error(f"Error loading prompts section: {e}") 

14949 return ORJSONResponse(content={"error": str(e)}, status_code=500) 

14950 

14951 

14952@admin_router.get("/sections/servers") 

14953@require_permission("servers.read", allow_admin_bypass=False) 

14954async def get_servers_section( 

14955 team_id: Optional[str] = None, 

14956 include_inactive: bool = False, 

14957 db: Session = Depends(get_db), 

14958 user=Depends(get_current_user_with_permissions), 

14959): 

14960 """Get servers data filtered by team. 

14961 

14962 Args: 

14963 team_id: Optional team ID to filter by 

14964 include_inactive: Whether to include inactive servers 

14965 db: Database session 

14966 user: Current authenticated user context 

14967 

14968 Returns: 

14969 JSONResponse: Servers data with team filtering applied 

14970 """ 

14971 try: 

14972 local_server_service = ServerService() 

14973 user_email = get_user_email(user) 

14974 LOGGER.debug(f"User {user_email} requesting servers section with team_id={team_id}, include_inactive={include_inactive}") 

14975 

14976 # Get servers with optional include_inactive parameter 

14977 servers_list = await local_server_service.list_servers(db, include_inactive=include_inactive) 

14978 

14979 # Apply team filtering if specified 

14980 if team_id: 

14981 servers_list = [s for s in servers_list if getattr(s, "team_id", None) == team_id] 

14982 

14983 # Convert to JSON-serializable format 

14984 servers = [] 

14985 for server in servers_list: 

14986 server_dict = ( 

14987 server.model_dump(by_alias=True) 

14988 if hasattr(server, "model_dump") 

14989 else { 

14990 "id": server.id, 

14991 "name": server.name, 

14992 "description": server.description, 

14993 "tags": server.tags or [], 

14994 "isActive": server.enabled, 

14995 "team_id": getattr(server, "team_id", None), 

14996 "visibility": getattr(server, "visibility", "private"), 

14997 } 

14998 ) 

14999 servers.append(server_dict) 

15000 

15001 return ORJSONResponse(content={"servers": servers, "team_id": team_id}) 

15002 

15003 except Exception as e: 

15004 LOGGER.error(f"Error loading servers section: {e}") 

15005 return ORJSONResponse(content={"error": str(e)}, status_code=500) 

15006 

15007 

15008@admin_router.get("/sections/gateways") 

15009@require_permission("gateways.read", allow_admin_bypass=False) 

15010async def get_gateways_section( 

15011 team_id: Optional[str] = None, 

15012 db: Session = Depends(get_db), 

15013 user=Depends(get_current_user_with_permissions), 

15014): 

15015 """Get gateways data filtered by team. 

15016 

15017 Args: 

15018 team_id: Optional team ID to filter by 

15019 db: Database session 

15020 user: Current authenticated user context 

15021 

15022 Returns: 

15023 JSONResponse: Gateways data with team filtering applied 

15024 """ 

15025 try: 

15026 local_gateway_service = GatewayService() 

15027 get_user_email(user) 

15028 

15029 # Get all gateways and filter by team 

15030 gateways_list, _ = await local_gateway_service.list_gateways(db, include_inactive=True) 

15031 

15032 # Apply team filtering if specified 

15033 if team_id: 

15034 gateways_list = [g for g in gateways_list if g.team_id == team_id] 

15035 

15036 # Convert to JSON-serializable format 

15037 gateways = [] 

15038 for gateway in gateways_list: 

15039 if hasattr(gateway, "model_dump"): 

15040 # Get dict and serialize datetime objects 

15041 gateway_dict = gateway.model_dump(by_alias=True) 

15042 # Convert datetime objects to strings 

15043 for key, value in gateway_dict.items(): 

15044 gateway_dict[key] = serialize_datetime(value) 

15045 else: 

15046 # Parse URL to extract host and port 

15047 parsed_url = urllib.parse.urlparse(gateway.url) if gateway.url else None 

15048 gateway_dict = { 

15049 "id": gateway.id, 

15050 "name": gateway.name, 

15051 "host": parsed_url.hostname if parsed_url else "", 

15052 "port": parsed_url.port if parsed_url else 80, 

15053 "tags": gateway.tags or [], 

15054 "isActive": getattr(gateway, "enabled", False), 

15055 "team_id": getattr(gateway, "team_id", None), 

15056 "visibility": getattr(gateway, "visibility", "private"), 

15057 "created_at": serialize_datetime(getattr(gateway, "created_at", None)), 

15058 "updated_at": serialize_datetime(getattr(gateway, "updated_at", None)), 

15059 } 

15060 gateways.append(gateway_dict) 

15061 

15062 return ORJSONResponse(content={"gateways": gateways, "team_id": team_id}) 

15063 

15064 except Exception as e: 

15065 LOGGER.error(f"Error loading gateways section: {e}") 

15066 return ORJSONResponse(content={"error": str(e)}, status_code=500) 

15067 

15068 

15069#################### 

15070# Plugin Routes # 

15071#################### 

15072 

15073 

15074@admin_router.get("/plugins/partial") 

15075@require_permission("admin.plugins", allow_admin_bypass=False) 

15076async def get_plugins_partial(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> HTMLResponse: # pylint: disable=unused-argument 

15077 """Render the plugins partial HTML template. 

15078 

15079 This endpoint returns a rendered HTML partial containing plugin information, 

15080 similar to the version_info_partial pattern. It's designed to be loaded via HTMX 

15081 into the admin interface. 

15082 

15083 Args: 

15084 request: FastAPI request object 

15085 db: Database session 

15086 user: Authenticated user 

15087 

15088 Returns: 

15089 HTMLResponse with rendered plugins partial template 

15090 """ 

15091 LOGGER.debug(f"User {get_user_email(user)} requested plugins partial") 

15092 

15093 try: 

15094 # Get plugin service and check if plugins are enabled 

15095 plugin_service = get_plugin_service() 

15096 

15097 # Check if plugin manager is available in app state 

15098 plugin_manager = getattr(request.app.state, "plugin_manager", None) 

15099 if plugin_manager: 

15100 plugin_service.set_plugin_manager(plugin_manager) 

15101 

15102 # Get plugin data 

15103 plugins = plugin_service.get_all_plugins() 

15104 stats = await plugin_service.get_plugin_statistics() 

15105 

15106 # Prepare context for template 

15107 context = {"request": request, "plugins": plugins, "stats": stats, "plugins_enabled": plugin_manager is not None, "root_path": request.scope.get("root_path", "")} 

15108 

15109 # Render the partial template 

15110 return request.app.state.templates.TemplateResponse(request, "plugins_partial.html", context) 

15111 

15112 except Exception as e: 

15113 LOGGER.error(f"Error rendering plugins partial: {e}") 

15114 # Return error HTML that can be displayed in the UI 

15115 error_html = f""" 

15116 <div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"> 

15117 <strong class="font-bold">Error loading plugins:</strong> 

15118 <span class="block sm:inline">{html.escape(str(e))}</span> 

15119 </div> 

15120 """ 

15121 return HTMLResponse(content=error_html, status_code=500) 

15122 

15123 

15124@admin_router.get("/plugins", response_model=PluginListResponse) 

15125@require_permission("admin.plugins", allow_admin_bypass=False) 

15126async def list_plugins( 

15127 request: Request, 

15128 search: Optional[str] = None, 

15129 mode: Optional[str] = None, 

15130 hook: Optional[str] = None, 

15131 tag: Optional[str] = None, 

15132 db: Session = Depends(get_db), # pylint: disable=unused-argument 

15133 user=Depends(get_current_user_with_permissions), 

15134) -> PluginListResponse: 

15135 """Get list of all plugins with optional filtering. 

15136 

15137 Args: 

15138 request: FastAPI request object 

15139 search: Optional text search in name/description/author 

15140 mode: Optional filter by mode (enforce/permissive/disabled) 

15141 hook: Optional filter by hook type 

15142 tag: Optional filter by tag 

15143 db: Database session 

15144 user: Authenticated user 

15145 

15146 Returns: 

15147 PluginListResponse with list of plugins and statistics 

15148 

15149 Raises: 

15150 HTTPException: If there's an error retrieving plugins 

15151 """ 

15152 LOGGER.debug(f"User {get_user_email(user)} requested plugin list") 

15153 structured_logger = get_structured_logger() 

15154 

15155 try: 

15156 # Get plugin service 

15157 plugin_service = get_plugin_service() 

15158 

15159 # Check if plugin manager is available 

15160 plugin_manager = getattr(request.app.state, "plugin_manager", None) 

15161 if plugin_manager: 

15162 plugin_service.set_plugin_manager(plugin_manager) 

15163 

15164 # Get filtered plugins 

15165 if any([search, mode, hook, tag]): 

15166 plugins = plugin_service.search_plugins(query=search, mode=mode, hook=hook, tag=tag) 

15167 else: 

15168 plugins = plugin_service.get_all_plugins() 

15169 

15170 # Count enabled/disabled 

15171 enabled_count = sum(1 for p in plugins if p["status"] == "enabled") 

15172 disabled_count = sum(1 for p in plugins if p["status"] == "disabled") 

15173 

15174 # Log plugin marketplace browsing activity 

15175 structured_logger.info( 

15176 "User browsed plugin marketplace", 

15177 user_id=get_user_id(user), 

15178 user_email=get_user_email(user), 

15179 component="plugin_marketplace", 

15180 category="business_logic", 

15181 resource_type="plugin_list", 

15182 resource_action="browse", 

15183 custom_fields={ 

15184 "search_query": search, 

15185 "filter_mode": mode, 

15186 "filter_hook": hook, 

15187 "filter_tag": tag, 

15188 "results_count": len(plugins), 

15189 "enabled_count": enabled_count, 

15190 "disabled_count": disabled_count, 

15191 "has_filters": any([search, mode, hook, tag]), 

15192 }, 

15193 ) 

15194 

15195 return PluginListResponse(plugins=plugins, total=len(plugins), enabled_count=enabled_count, disabled_count=disabled_count) 

15196 

15197 except Exception as e: 

15198 LOGGER.error(f"Error listing plugins: {e}") 

15199 structured_logger.error("Failed to list plugins in marketplace", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic") 

15200 raise HTTPException(status_code=500, detail=str(e)) 

15201 

15202 

15203@admin_router.get("/plugins/stats", response_model=PluginStatsResponse) 

15204@require_permission("admin.plugins", allow_admin_bypass=False) 

15205async def get_plugin_stats(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> PluginStatsResponse: # pylint: disable=unused-argument 

15206 """Get plugin statistics. 

15207 

15208 Args: 

15209 request: FastAPI request object 

15210 db: Database session 

15211 user: Authenticated user 

15212 

15213 Returns: 

15214 PluginStatsResponse with aggregated plugin statistics 

15215 

15216 Raises: 

15217 HTTPException: If there's an error getting plugin statistics 

15218 """ 

15219 LOGGER.debug(f"User {get_user_email(user)} requested plugin statistics") 

15220 structured_logger = get_structured_logger() 

15221 

15222 try: 

15223 # Get plugin service 

15224 plugin_service = get_plugin_service() 

15225 

15226 # Check if plugin manager is available 

15227 plugin_manager = getattr(request.app.state, "plugin_manager", None) 

15228 if plugin_manager: 

15229 plugin_service.set_plugin_manager(plugin_manager) 

15230 

15231 # Get statistics 

15232 stats = await plugin_service.get_plugin_statistics() 

15233 

15234 # Log marketplace analytics access 

15235 structured_logger.info( 

15236 "User accessed plugin marketplace statistics", 

15237 user_id=get_user_id(user), 

15238 user_email=get_user_email(user), 

15239 component="plugin_marketplace", 

15240 category="business_logic", 

15241 resource_type="plugin_stats", 

15242 resource_action="view", 

15243 custom_fields={ 

15244 "total_plugins": stats.get("total_plugins", 0), 

15245 "enabled_plugins": stats.get("enabled_plugins", 0), 

15246 "disabled_plugins": stats.get("disabled_plugins", 0), 

15247 "hooks_count": len(stats.get("plugins_by_hook", {})), 

15248 "tags_count": len(stats.get("plugins_by_tag", {})), 

15249 "authors_count": len(stats.get("plugins_by_author", {})), 

15250 }, 

15251 ) 

15252 

15253 return PluginStatsResponse(**stats) 

15254 

15255 except Exception as e: 

15256 LOGGER.error(f"Error getting plugin statistics: {e}") 

15257 structured_logger.error( 

15258 "Failed to get plugin marketplace statistics", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic" 

15259 ) 

15260 raise HTTPException(status_code=500, detail=str(e)) 

15261 

15262 

15263@admin_router.get("/plugins/{name}", response_model=PluginDetail) 

15264@require_permission("admin.plugins", allow_admin_bypass=False) 

15265async def get_plugin_details(name: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> PluginDetail: # pylint: disable=unused-argument 

15266 """Get detailed information about a specific plugin. 

15267 

15268 Args: 

15269 name: Plugin name 

15270 request: FastAPI request object 

15271 db: Database session 

15272 user: Authenticated user 

15273 

15274 Returns: 

15275 PluginDetail with full plugin information 

15276 

15277 Raises: 

15278 HTTPException: If plugin not found 

15279 """ 

15280 LOGGER.debug(f"User {get_user_email(user)} requested details for plugin {name}") 

15281 structured_logger = get_structured_logger() 

15282 audit_service = get_audit_trail_service() 

15283 

15284 try: 

15285 # Get plugin service 

15286 plugin_service = get_plugin_service() 

15287 

15288 # Check if plugin manager is available 

15289 plugin_manager = getattr(request.app.state, "plugin_manager", None) 

15290 if plugin_manager: 

15291 plugin_service.set_plugin_manager(plugin_manager) 

15292 

15293 # Get plugin details 

15294 plugin = plugin_service.get_plugin_by_name(name) 

15295 

15296 if not plugin: 

15297 structured_logger.warning( 

15298 f"Plugin '{name}' not found in marketplace", 

15299 user_id=get_user_id(user), 

15300 user_email=get_user_email(user), 

15301 component="plugin_marketplace", 

15302 category="business_logic", 

15303 custom_fields={"plugin_name": name, "action": "view_details"}, 

15304 ) 

15305 raise HTTPException(status_code=404, detail=f"Plugin '{name}' not found") 

15306 

15307 # Log plugin view activity 

15308 structured_logger.info( 

15309 f"User viewed plugin details: '{name}'", 

15310 user_id=get_user_id(user), 

15311 user_email=get_user_email(user), 

15312 component="plugin_marketplace", 

15313 category="business_logic", 

15314 resource_type="plugin", 

15315 resource_id=name, 

15316 resource_action="view_details", 

15317 custom_fields={ 

15318 "plugin_name": name, 

15319 "plugin_version": plugin.get("version"), 

15320 "plugin_author": plugin.get("author"), 

15321 "plugin_status": plugin.get("status"), 

15322 "plugin_mode": plugin.get("mode"), 

15323 "plugin_hooks": plugin.get("hooks", []), 

15324 "plugin_tags": plugin.get("tags", []), 

15325 }, 

15326 ) 

15327 

15328 # Create audit trail for plugin access 

15329 audit_service.log_audit( 

15330 user_id=get_user_id(user), user_email=get_user_email(user), resource_type="plugin", resource_id=name, action="view", description=f"Viewed plugin '{name}' details in marketplace", db=db 

15331 ) 

15332 

15333 return PluginDetail(**plugin) 

15334 

15335 except HTTPException: 

15336 raise 

15337 except Exception as e: 

15338 LOGGER.error(f"Error getting plugin details: {e}") 

15339 structured_logger.error( 

15340 f"Failed to get plugin details: '{name}'", user_id=get_user_id(user), user_email=get_user_email(user), error=e, component="plugin_marketplace", category="business_logic" 

15341 ) 

15342 raise HTTPException(status_code=500, detail=str(e)) 

15343 

15344 

15345################################################## 

15346# MCP Registry Endpoints 

15347################################################## 

15348 

15349 

15350@admin_router.get("/mcp-registry/servers", response_model=CatalogListResponse) 

15351@require_permission("servers.read", allow_admin_bypass=False) 

15352async def list_catalog_servers( 

15353 _request: Request, 

15354 category: Optional[str] = None, 

15355 auth_type: Optional[str] = None, 

15356 provider: Optional[str] = None, 

15357 search: Optional[str] = None, 

15358 tags: Optional[List[str]] = Query(None), 

15359 show_registered_only: bool = False, 

15360 show_available_only: bool = True, 

15361 limit: int = 100, 

15362 offset: int = 0, 

15363 db: Session = Depends(get_db), 

15364 _user=Depends(get_current_user_with_permissions), 

15365) -> CatalogListResponse: 

15366 """Get list of catalog servers with filtering. 

15367 

15368 Args: 

15369 _request: FastAPI request object 

15370 category: Filter by category 

15371 auth_type: Filter by authentication type 

15372 provider: Filter by provider 

15373 search: Search in name/description 

15374 tags: Filter by tags 

15375 show_registered_only: Show only already registered servers 

15376 show_available_only: Show only available servers 

15377 limit: Maximum results 

15378 offset: Pagination offset 

15379 db: Database session 

15380 _user: Authenticated user 

15381 

15382 Returns: 

15383 List of catalog servers matching filters 

15384 

15385 Raises: 

15386 HTTPException: If the catalog feature is disabled. 

15387 """ 

15388 if not settings.mcpgateway_catalog_enabled: 

15389 raise HTTPException(status_code=404, detail="Catalog feature is disabled") 

15390 

15391 catalog_request = CatalogListRequest( 

15392 category=category, 

15393 auth_type=auth_type, 

15394 provider=provider, 

15395 search=search, 

15396 tags=tags or [], 

15397 show_registered_only=show_registered_only, 

15398 show_available_only=show_available_only, 

15399 limit=limit, 

15400 offset=offset, 

15401 ) 

15402 

15403 return await catalog_service.get_catalog_servers(catalog_request, db) 

15404 

15405 

15406@admin_router.post("/mcp-registry/{server_id}/register", response_model=CatalogServerRegisterResponse) 

15407@require_permission("servers.create", allow_admin_bypass=False) 

15408async def register_catalog_server( 

15409 server_id: str, 

15410 http_request: Request, 

15411 request: Optional[CatalogServerRegisterRequest] = None, 

15412 db: Session = Depends(get_db), 

15413 _user=Depends(get_current_user_with_permissions), 

15414) -> Union[CatalogServerRegisterResponse, HTMLResponse]: 

15415 """Register a catalog server. 

15416 

15417 Args: 

15418 server_id: Catalog server ID to register 

15419 http_request: FastAPI request object (for HTMX detection) 

15420 request: Optional registration parameters 

15421 db: Database session 

15422 _user: Authenticated user 

15423 

15424 Returns: 

15425 Registration response with success status (JSON or HTML) 

15426 

15427 Raises: 

15428 HTTPException: If the catalog feature is disabled. 

15429 """ 

15430 if not settings.mcpgateway_catalog_enabled: 

15431 raise HTTPException(status_code=404, detail="Catalog feature is disabled") 

15432 

15433 result = await catalog_service.register_catalog_server(catalog_id=server_id, request=request, db=db) 

15434 

15435 # Check if this is an HTMX request 

15436 is_htmx = http_request.headers.get("HX-Request") == "true" 

15437 

15438 if is_htmx: 

15439 # Return HTML fragment for HTMX - properly escape all dynamic values 

15440 safe_server_id = html.escape(server_id, quote=True) 

15441 safe_message = html.escape(result.message, quote=True) 

15442 

15443 if result.success: 

15444 # Check if this is an OAuth server requiring configuration (use explicit flag, not string matching) 

15445 if result.oauth_required: 

15446 # OAuth servers are registered but disabled until configured 

15447 button_fragment = f""" 

15448 <button 

15449 class="w-full px-4 py-2 bg-yellow-600 text-white rounded-md cursor-default" 

15450 disabled 

15451 title="{safe_message}" 

15452 > 

15453 <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 

15454 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path> 

15455 </svg> 

15456 OAuth Config Required 

15457 </button> 

15458 """ 

15459 # Trigger refresh - template will show yellow state from requires_oauth_config field 

15460 response = HTMLResponse(content=button_fragment) 

15461 response.headers["HX-Trigger-After-Swap"] = orjson.dumps({"catalogRegistrationSuccess": {"delayMs": 1500}}).decode() 

15462 return response 

15463 # Success: Show success button state 

15464 button_fragment = f""" 

15465 <button 

15466 class="w-full px-4 py-2 bg-green-600 text-white rounded-md cursor-default" 

15467 disabled 

15468 title="{safe_message}" 

15469 > 

15470 <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 

15471 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> 

15472 </svg> 

15473 Registered Successfully 

15474 </button> 

15475 """ 

15476 # Only non-OAuth success triggers delayed table refresh 

15477 response = HTMLResponse(content=button_fragment) 

15478 response.headers["HX-Trigger-After-Swap"] = orjson.dumps({"catalogRegistrationSuccess": {"delayMs": 1500}}).decode() 

15479 return response 

15480 # Error: Show error state with retry button (no auto-refresh so retry persists) 

15481 error_msg = html.escape(result.error or result.message, quote=True) 

15482 button_fragment = f""" 

15483 <button 

15484 id="{safe_server_id}-register-btn" 

15485 class="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors" 

15486 hx-post="{settings.app_root_path}/admin/mcp-registry/{safe_server_id}/register" 

15487 hx-target="#{safe_server_id}-button-container" 

15488 hx-swap="innerHTML" 

15489 hx-disabled-elt="this" 

15490 hx-on::before-request="this.innerHTML = '<span class=\\'inline-flex items-center\\'><span class=\\'inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2\\'></span>Retrying...</span>'" 

15491 hx-on::response-error="this.innerHTML = '<span class=\\'inline-flex items-center\\'><svg class=\\'inline-block h-4 w-4 mr-2\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\\'></path></svg>Network Error - Click to Retry</span>'" 

15492 title="{error_msg}" 

15493 > 

15494 <svg class="inline-block h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 

15495 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> 

15496 </svg> 

15497 Failed - Click to Retry 

15498 </button> 

15499 """ 

15500 # No HX-Trigger for errors - let the retry button persist 

15501 return HTMLResponse(content=button_fragment) 

15502 

15503 # Return JSON for non-HTMX requests (API clients) 

15504 return result 

15505 

15506 

15507@admin_router.get("/mcp-registry/{server_id}/status", response_model=CatalogServerStatusResponse) 

15508@require_permission("servers.read", allow_admin_bypass=False) 

15509async def check_catalog_server_status( 

15510 server_id: str, 

15511 _db: Session = Depends(get_db), 

15512 _user=Depends(get_current_user_with_permissions), 

15513) -> CatalogServerStatusResponse: 

15514 """Check catalog server availability. 

15515 

15516 Args: 

15517 server_id: Catalog server ID to check 

15518 _db: Database session 

15519 _user: Authenticated user 

15520 

15521 Returns: 

15522 Server status including availability and response time 

15523 

15524 Raises: 

15525 HTTPException: If the catalog feature is disabled. 

15526 """ 

15527 if not settings.mcpgateway_catalog_enabled: 

15528 raise HTTPException(status_code=404, detail="Catalog feature is disabled") 

15529 

15530 return await catalog_service.check_server_availability(server_id) 

15531 

15532 

15533@admin_router.post("/mcp-registry/bulk-register", response_model=CatalogBulkRegisterResponse) 

15534@require_permission("servers.create", allow_admin_bypass=False) 

15535async def bulk_register_catalog_servers( 

15536 request: CatalogBulkRegisterRequest, 

15537 db: Session = Depends(get_db), 

15538 _user=Depends(get_current_user_with_permissions), 

15539) -> CatalogBulkRegisterResponse: 

15540 """Register multiple catalog servers at once. 

15541 

15542 Args: 

15543 request: Bulk registration request with server IDs 

15544 db: Database session 

15545 _user: Authenticated user 

15546 

15547 Returns: 

15548 Bulk registration response with success/failure details 

15549 

15550 Raises: 

15551 HTTPException: If the catalog feature is disabled. 

15552 """ 

15553 if not settings.mcpgateway_catalog_enabled: 

15554 raise HTTPException(status_code=404, detail="Catalog feature is disabled") 

15555 

15556 return await catalog_service.bulk_register_servers(request, db) 

15557 

15558 

15559@admin_router.get("/mcp-registry/partial") 

15560@require_permission("servers.read", allow_admin_bypass=False) 

15561async def catalog_partial( 

15562 request: Request, 

15563 category: Optional[str] = None, 

15564 auth_type: Optional[str] = None, 

15565 search: Optional[str] = None, 

15566 page: int = 1, 

15567 db: Session = Depends(get_db), 

15568 _user=Depends(get_current_user_with_permissions), 

15569) -> HTMLResponse: 

15570 """Get HTML partial for catalog servers (used by HTMX). 

15571 

15572 Args: 

15573 request: FastAPI request object 

15574 category: Filter by category 

15575 auth_type: Filter by authentication type 

15576 search: Search term 

15577 page: Page number (1-indexed) 

15578 db: Database session 

15579 _user: Authenticated user 

15580 

15581 Returns: 

15582 HTML partial with filtered catalog servers 

15583 

15584 Raises: 

15585 HTTPException: If the catalog feature is disabled. 

15586 """ 

15587 if not settings.mcpgateway_catalog_enabled: 

15588 raise HTTPException(status_code=404, detail="Catalog feature is disabled") 

15589 

15590 root_path = request.scope.get("root_path", "") 

15591 

15592 # Calculate pagination 

15593 page_size = settings.mcpgateway_catalog_page_size 

15594 offset = (page - 1) * page_size 

15595 

15596 catalog_request = CatalogListRequest(category=category, auth_type=auth_type, search=search, show_available_only=False, limit=page_size, offset=offset) 

15597 

15598 response = await catalog_service.get_catalog_servers(catalog_request, db) 

15599 

15600 # Get ALL servers (no filters, no pagination) for counting statistics 

15601 all_servers_request = CatalogListRequest(show_available_only=False, limit=1000, offset=0) 

15602 all_servers_response = await catalog_service.get_catalog_servers(all_servers_request, db) 

15603 

15604 # Pass filter parameters to template for pagination links 

15605 filter_params = { 

15606 "category": category, 

15607 "auth_type": auth_type, 

15608 "search": search, 

15609 } 

15610 

15611 # Calculate statistics and pagination info 

15612 total_servers = response.total 

15613 registered_count = sum(1 for s in response.servers if s.is_registered) 

15614 total_pages = (total_servers + page_size - 1) // page_size # Ceiling division 

15615 

15616 # Count ALL servers by category, auth type, and provider (not just current page) 

15617 servers_by_category = {} 

15618 servers_by_auth_type = {} 

15619 servers_by_provider = {} 

15620 

15621 for server in all_servers_response.servers: 

15622 servers_by_category[server.category] = servers_by_category.get(server.category, 0) + 1 

15623 servers_by_auth_type[server.auth_type] = servers_by_auth_type.get(server.auth_type, 0) + 1 

15624 servers_by_provider[server.provider] = servers_by_provider.get(server.provider, 0) + 1 

15625 

15626 stats = { 

15627 "total_servers": all_servers_response.total, # Use total from all servers 

15628 "registered_servers": registered_count, 

15629 "categories": all_servers_response.categories, 

15630 "auth_types": all_servers_response.auth_types, 

15631 "providers": all_servers_response.providers, 

15632 "servers_by_category": servers_by_category, 

15633 "servers_by_auth_type": servers_by_auth_type, 

15634 "servers_by_provider": servers_by_provider, 

15635 } 

15636 

15637 context = { 

15638 "request": request, 

15639 "servers": response.servers, 

15640 "stats": stats, 

15641 "root_path": root_path, 

15642 "page": page, 

15643 "total_pages": total_pages, 

15644 "page_size": page_size, 

15645 "filter_params": filter_params, 

15646 } 

15647 

15648 return request.app.state.templates.TemplateResponse(request, "mcp_registry_partial.html", context) 

15649 

15650 

15651# =================================== 

15652# System Metrics Endpoints 

15653# =================================== 

15654 

15655 

15656@admin_router.get("/system/stats") 

15657@require_permission("admin.system_config", allow_admin_bypass=False) 

15658async def get_system_stats( 

15659 request: Request, 

15660 db: Session = Depends(get_db), 

15661 user=Depends(get_current_user_with_permissions), 

15662): 

15663 """Get comprehensive system metrics for administrators. 

15664 

15665 Returns detailed counts across all entity types including users, teams, 

15666 MCP resources (servers, tools, resources, prompts, A2A agents, gateways), 

15667 API tokens, sessions, metrics, security events, and workflow state. 

15668 

15669 Designed for capacity planning, performance optimization, and demonstrating 

15670 system capabilities to administrators. 

15671 

15672 Args: 

15673 request: FastAPI request object 

15674 db: Database session dependency 

15675 user: Authenticated user from dependency (must have admin access) 

15676 

15677 Returns: 

15678 HTMLResponse or JSONResponse: Comprehensive system metrics 

15679 Returns HTML partial when requested via HTMX, JSON otherwise 

15680 

15681 Raises: 

15682 HTTPException: If metrics collection fails 

15683 

15684 Examples: 

15685 >>> # Request system metrics via API 

15686 >>> # GET /admin/system/stats 

15687 >>> # Returns JSON with users, teams, mcp_resources, tokens, sessions, metrics, security, workflow 

15688 """ 

15689 try: 

15690 LOGGER.info(f"System metrics requested by user: {user}") 

15691 

15692 # First-Party 

15693 from mcpgateway.services.system_stats_service import SystemStatsService # pylint: disable=import-outside-toplevel 

15694 

15695 # Get metrics (using cached version for performance) 

15696 service = SystemStatsService() 

15697 stats = await service.get_comprehensive_stats_cached(db) 

15698 

15699 LOGGER.info(f"System metrics retrieved successfully for user {user}") 

15700 

15701 # Check if this is an HTMX request for HTML partial 

15702 if request.headers.get("hx-request"): 

15703 # Return HTML partial for HTMX 

15704 return request.app.state.templates.TemplateResponse( 

15705 request, 

15706 "metrics_partial.html", 

15707 { 

15708 "request": request, 

15709 "stats": stats, 

15710 "root_path": request.scope.get("root_path", ""), 

15711 "db_metrics_recording_enabled": settings.db_metrics_recording_enabled, 

15712 }, 

15713 ) 

15714 

15715 # Return JSON for API requests 

15716 return ORJSONResponse(content=stats) 

15717 

15718 except Exception as e: 

15719 LOGGER.error(f"System metrics retrieval failed for user {user}: {str(e)}", exc_info=True) 

15720 raise HTTPException(status_code=500, detail=f"Failed to retrieve system metrics: {str(e)}") 

15721 

15722 

15723# =================================== 

15724# Support Bundle Endpoints 

15725# =================================== 

15726 

15727 

15728@admin_router.get("/support-bundle/generate") 

15729@require_permission("admin.system_config", allow_admin_bypass=False) 

15730async def admin_generate_support_bundle( 

15731 log_lines: int = Query(default=1000, description="Number of log lines to include"), 

15732 include_logs: bool = Query(default=True, description="Include log files"), 

15733 include_env: bool = Query(default=True, description="Include environment config"), 

15734 include_system: bool = Query(default=True, description="Include system info"), 

15735 user=Depends(get_current_user_with_permissions), 

15736 _db: Session = Depends(get_db), 

15737): 

15738 """ 

15739 Generate and download a support bundle with sanitized diagnostics. 

15740 

15741 Creates a ZIP file containing version info, system diagnostics, configuration, 

15742 and logs with automatic sanitization of sensitive data (passwords, tokens, secrets). 

15743 

15744 Args: 

15745 log_lines: Number of log lines to include (default: 1000, 0 = all) 

15746 include_logs: Include log files in bundle (default: True) 

15747 include_env: Include environment configuration (default: True) 

15748 include_system: Include system diagnostics (default: True) 

15749 user: Authenticated user from dependency 

15750 _db: Database session for permission checks. 

15751 

15752 Returns: 

15753 Response: ZIP file download with support bundle 

15754 

15755 Raises: 

15756 HTTPException: If bundle generation fails 

15757 

15758 Examples: 

15759 >>> # Request support bundle via API 

15760 >>> # GET /admin/support-bundle/generate?log_lines=500 

15761 >>> # Returns: mcpgateway-support-YYYY-MM-DD-HHMMSS.zip 

15762 """ 

15763 try: 

15764 LOGGER.info(f"Support bundle generation requested by user: {user}") 

15765 

15766 # First-Party 

15767 from mcpgateway.services.support_bundle_service import SupportBundleConfig, SupportBundleService # pylint: disable=import-outside-toplevel 

15768 

15769 # Create configuration 

15770 config = SupportBundleConfig( 

15771 include_logs=include_logs, 

15772 include_env=include_env, 

15773 include_system_info=include_system, 

15774 log_tail_lines=log_lines, 

15775 output_dir=Path(tempfile.gettempdir()), 

15776 ) 

15777 

15778 # Generate bundle 

15779 service = SupportBundleService() 

15780 bundle_path = service.generate_bundle(config) 

15781 

15782 # Return as downloadable file using FileResponse (streams asynchronously) 

15783 timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") 

15784 filename = f"mcpgateway-support-{timestamp}.zip" 

15785 

15786 # Pre-stat for Content-Length header and logging 

15787 bundle_stat = bundle_path.stat() 

15788 LOGGER.info(f"Support bundle generated successfully for user {user}: {filename} ({bundle_stat.st_size} bytes)") 

15789 

15790 # Use BackgroundTask to clean up temp file after response is sent 

15791 return FileResponse( 

15792 path=bundle_path, 

15793 media_type="application/zip", 

15794 filename=filename, 

15795 stat_result=bundle_stat, 

15796 background=BackgroundTask(lambda: bundle_path.unlink(missing_ok=True)), 

15797 ) 

15798 

15799 except Exception as e: 

15800 LOGGER.error(f"Support bundle generation failed for user {user}: {str(e)}", exc_info=True) 

15801 raise HTTPException(status_code=500, detail=f"Failed to generate support bundle: {str(e)}") 

15802 

15803 

15804# ============================================================================ 

15805# Maintenance Routes (Platform Admin Only) 

15806# ============================================================================ 

15807 

15808 

15809@admin_router.get("/maintenance/partial", response_class=HTMLResponse) 

15810@require_permission("admin.system_config", allow_admin_bypass=False) 

15811async def get_maintenance_partial( 

15812 request: Request, 

15813 _user=Depends(get_current_user_with_permissions), 

15814 _db: Session = Depends(get_db), 

15815): 

15816 """Render the maintenance dashboard partial (platform admin only). 

15817 

15818 This endpoint returns the maintenance UI panel which includes: 

15819 - Metrics cleanup controls 

15820 - Metrics rollup controls 

15821 - System health status 

15822 

15823 Only platform administrators can access this endpoint. 

15824 

15825 Args: 

15826 request: FastAPI request object 

15827 _user: Authenticated user with admin permissions 

15828 _db: Database session for permission checks. 

15829 

15830 Returns: 

15831 HTMLResponse: Rendered maintenance dashboard template 

15832 

15833 Raises: 

15834 HTTPException: 403 if user is not a platform admin 

15835 """ 

15836 root_path = request.scope.get("root_path", "") 

15837 

15838 # Build payload with settings for the template 

15839 payload = { 

15840 "settings": { 

15841 "metrics_cleanup_enabled": getattr(settings, "metrics_cleanup_enabled", False), 

15842 "metrics_rollup_enabled": getattr(settings, "metrics_rollup_enabled", False), 

15843 "metrics_retention_days": getattr(settings, "metrics_retention_days", 30), 

15844 } 

15845 } 

15846 

15847 return request.app.state.templates.TemplateResponse( 

15848 request, 

15849 "maintenance_partial.html", 

15850 {"request": request, "payload": payload, "root_path": root_path}, 

15851 ) 

15852 

15853 

15854# ============================================================================ 

15855# Observability Routes 

15856# ============================================================================ 

15857 

15858 

15859@admin_router.get("/observability/partial", response_class=HTMLResponse) 

15860@require_permission("admin.system_config", allow_admin_bypass=False) 

15861async def get_observability_partial(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)): 

15862 """Render the observability dashboard partial. 

15863 

15864 Args: 

15865 request: FastAPI request object 

15866 _user: Authenticated user with admin permissions (required by dependency) 

15867 _db: Database session for permission checks. 

15868 

15869 Returns: 

15870 HTMLResponse: Rendered observability dashboard template 

15871 """ 

15872 root_path = request.scope.get("root_path", "") 

15873 return request.app.state.templates.TemplateResponse(request, "observability_partial.html", {"request": request, "root_path": root_path}) 

15874 

15875 

15876@admin_router.get("/observability/metrics/partial", response_class=HTMLResponse) 

15877@require_permission("admin.system_config", allow_admin_bypass=False) 

15878async def get_observability_metrics_partial(request: Request, _user=Depends(get_current_user_with_permissions), _db: Session = Depends(get_db)): 

15879 """Render the advanced metrics dashboard partial. 

15880 

15881 Args: 

15882 request: FastAPI request object 

15883 _user: Authenticated user with admin permissions (required by dependency) 

15884 _db: Database session for permission checks. 

15885 

15886 Returns: 

15887 HTMLResponse: Rendered metrics dashboard template 

15888 """ 

15889 root_path = request.scope.get("root_path", "") 

15890 return request.app.state.templates.TemplateResponse(request, "observability_metrics.html", {"request": request, "root_path": root_path}) 

15891 

15892 

15893@admin_router.get("/observability/stats", response_class=HTMLResponse) 

15894@require_permission("admin.system_config", allow_admin_bypass=False) 

15895async def get_observability_stats(request: Request, hours: int = Query(24, ge=1, le=168), _user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): 

15896 """Get observability statistics for the dashboard. 

15897 

15898 Args: 

15899 request: FastAPI request object 

15900 hours: Number of hours to look back for statistics (1-168) 

15901 _user: Authenticated user with admin permissions (required by dependency) 

15902 db: Database session for permission checks. 

15903 

15904 Returns: 

15905 HTMLResponse: Rendered statistics template with trace counts and averages 

15906 """ 

15907 db = next(get_db()) 

15908 try: 

15909 cutoff_time = datetime.now() - timedelta(hours=hours) 

15910 

15911 # Consolidate multiple count queries into a single aggregated select 

15912 # Filter by start_time first (uses index), then aggregate by status 

15913 result = db.execute( 

15914 select( 

15915 func.count(ObservabilityTrace.trace_id).label("total_traces"), # pylint: disable=not-callable 

15916 func.sum(case((ObservabilityTrace.status == "ok", 1), else_=0)).label("success_count"), 

15917 func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)).label("error_count"), 

15918 func.avg(ObservabilityTrace.duration_ms).label("avg_duration_ms"), 

15919 ).where(ObservabilityTrace.start_time >= cutoff_time) 

15920 ).one() 

15921 

15922 stats = { 

15923 "total_traces": int(result.total_traces or 0), 

15924 "success_count": int(result.success_count or 0), 

15925 "error_count": int(result.error_count or 0), 

15926 "avg_duration_ms": float(result.avg_duration_ms or 0), 

15927 } 

15928 

15929 return request.app.state.templates.TemplateResponse(request, "observability_stats.html", {"request": request, "stats": stats}) 

15930 finally: 

15931 # Ensure close() always runs even if commit() fails 

15932 try: 

15933 db.commit() # Commit read-only transaction to avoid implicit rollback 

15934 finally: 

15935 db.close() 

15936 

15937 

15938@admin_router.get("/observability/traces", response_class=HTMLResponse) 

15939@require_permission("admin.system_config", allow_admin_bypass=False) 

15940async def get_observability_traces( 

15941 request: Request, 

15942 time_range: str = Query("24h"), 

15943 status_filter: str = Query("all"), 

15944 limit: int = Query(50), 

15945 min_duration: Optional[float] = Query(None), 

15946 max_duration: Optional[float] = Query(None), 

15947 http_method: Optional[str] = Query(None), 

15948 user_email: Optional[str] = Query(None), 

15949 name_search: Optional[str] = Query(None), 

15950 attribute_search: Optional[str] = Query(None), 

15951 tool_name: Optional[str] = Query(None), 

15952 _user=Depends(get_current_user_with_permissions), 

15953 db: Session = Depends(get_db), 

15954): 

15955 """Get list of traces for the dashboard. 

15956 

15957 Args: 

15958 request: FastAPI request object 

15959 time_range: Time range filter (1h, 6h, 24h, 7d) 

15960 status_filter: Status filter (all, ok, error) 

15961 limit: Maximum number of traces to return 

15962 min_duration: Minimum duration in ms 

15963 max_duration: Maximum duration in ms 

15964 http_method: HTTP method filter 

15965 user_email: User email filter 

15966 name_search: Trace name search 

15967 attribute_search: Full-text attribute search 

15968 tool_name: Filter by tool name (shows traces that invoked this tool) 

15969 _user: Authenticated user with admin permissions (required by dependency) 

15970 db: Database session for permission checks. 

15971 

15972 Returns: 

15973 HTMLResponse: Rendered traces list template 

15974 """ 

15975 db = next(get_db()) 

15976 try: 

15977 # Parse time range 

15978 time_map = {"1h": 1, "6h": 6, "24h": 24, "7d": 168} 

15979 hours = time_map.get(time_range, 24) 

15980 cutoff_time = datetime.now() - timedelta(hours=hours) 

15981 

15982 query = db.query(ObservabilityTrace).filter(ObservabilityTrace.start_time >= cutoff_time) 

15983 

15984 # Apply status filter 

15985 if status_filter != "all": 

15986 query = query.filter(ObservabilityTrace.status == status_filter) 

15987 

15988 # Apply duration filters 

15989 if min_duration is not None: 

15990 query = query.filter(ObservabilityTrace.duration_ms >= min_duration) 

15991 if max_duration is not None: 

15992 query = query.filter(ObservabilityTrace.duration_ms <= max_duration) 

15993 

15994 # Apply HTTP method filter 

15995 if http_method: 

15996 query = query.filter(ObservabilityTrace.http_method == http_method) 

15997 

15998 # Apply user email filter 

15999 if user_email: 

16000 query = query.filter(ObservabilityTrace.user_email.ilike(f"%{user_email}%")) 

16001 

16002 # Apply name search 

16003 if name_search: 

16004 query = query.filter(ObservabilityTrace.name.ilike(f"%{name_search}%")) 

16005 

16006 # Apply attribute search 

16007 if attribute_search: 

16008 # Escape special characters for SQL LIKE 

16009 safe_search = attribute_search.replace("%", "\\%").replace("_", "\\_") 

16010 query = query.filter(cast(ObservabilityTrace.attributes, String).ilike(f"%{safe_search}%")) 

16011 

16012 # Apply tool name filter (join with spans to find traces that invoked a specific tool) 

16013 if tool_name: 

16014 # Subquery to find trace_ids that have tool invocations matching the tool name 

16015 tool_trace_ids = ( 

16016 db.query(ObservabilitySpan.trace_id) 

16017 .filter( 

16018 ObservabilitySpan.name == "tool.invoke", 

16019 extract_json_field(ObservabilitySpan.attributes, '$."tool.name"').ilike(f"%{tool_name}%"), 

16020 ) 

16021 .distinct() 

16022 .subquery() 

16023 ) 

16024 query = query.filter(ObservabilityTrace.trace_id.in_(select(tool_trace_ids.c.trace_id))) 

16025 

16026 # Get traces ordered by most recent 

16027 traces = query.order_by(ObservabilityTrace.start_time.desc()).limit(limit).all() 

16028 

16029 root_path = request.scope.get("root_path", "") 

16030 return request.app.state.templates.TemplateResponse(request, "observability_traces_list.html", {"request": request, "traces": traces, "root_path": root_path}) 

16031 finally: 

16032 # Ensure close() always runs even if commit() fails 

16033 try: 

16034 db.commit() # Commit read-only transaction to avoid implicit rollback 

16035 finally: 

16036 db.close() 

16037 

16038 

16039@admin_router.get("/observability/trace/{trace_id}", response_class=HTMLResponse) 

16040@require_permission("admin.system_config", allow_admin_bypass=False) 

16041async def get_observability_trace_detail(request: Request, trace_id: str, _user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): 

16042 """Get detailed trace information with spans. 

16043 

16044 Args: 

16045 request: FastAPI request object 

16046 trace_id: UUID of the trace to retrieve 

16047 _user: Authenticated user with admin permissions (required by dependency) 

16048 db: Database session for permission checks. 

16049 

16050 Returns: 

16051 HTMLResponse: Rendered trace detail template with waterfall view 

16052 

16053 Raises: 

16054 HTTPException: 404 if trace not found 

16055 """ 

16056 db = next(get_db()) 

16057 try: 

16058 trace = db.query(ObservabilityTrace).filter_by(trace_id=trace_id).options(joinedload(ObservabilityTrace.spans).joinedload(ObservabilitySpan.events)).first() 

16059 

16060 if not trace: 

16061 raise HTTPException(status_code=404, detail="Trace not found") 

16062 

16063 root_path = request.scope.get("root_path", "") 

16064 return request.app.state.templates.TemplateResponse(request, "observability_trace_detail.html", {"request": request, "trace": trace, "root_path": root_path}) 

16065 finally: 

16066 # Ensure close() always runs even if commit() fails 

16067 try: 

16068 db.commit() # Commit read-only transaction to avoid implicit rollback 

16069 finally: 

16070 db.close() 

16071 

16072 

16073@admin_router.post("/observability/queries", response_model=dict) 

16074@require_permission("admin.system_config", allow_admin_bypass=False) 

16075async def save_observability_query( 

16076 request: Request, # pylint: disable=unused-argument 

16077 name: str = Body(..., description="Name for the saved query"), 

16078 description: Optional[str] = Body(None, description="Optional description"), 

16079 filter_config: dict = Body(..., description="Filter configuration as JSON"), 

16080 is_shared: bool = Body(False, description="Whether query is shared with team"), 

16081 user=Depends(get_current_user_with_permissions), 

16082 db: Session = Depends(get_db), 

16083): 

16084 """Save a new observability query filter configuration. 

16085 

16086 Args: 

16087 request: FastAPI request object 

16088 name: User-given name for the query 

16089 description: Optional description 

16090 filter_config: Dictionary containing all filter values 

16091 is_shared: Whether this query is visible to other users 

16092 user: Authenticated user (required by dependency) 

16093 db: Database session for permission checks. 

16094 

16095 Returns: 

16096 dict: Created query details with id 

16097 

16098 Raises: 

16099 HTTPException: 400 if validation fails 

16100 """ 

16101 db = next(get_db()) 

16102 try: 

16103 # Get user email from authenticated user 

16104 user_email = user.email if hasattr(user, "email") else "unknown" 

16105 

16106 # Create new saved query 

16107 query = ObservabilitySavedQuery(name=name, description=description, user_email=user_email, filter_config=filter_config, is_shared=is_shared) 

16108 

16109 db.add(query) 

16110 db.commit() 

16111 db.refresh(query) 

16112 

16113 return {"id": query.id, "name": query.name, "description": query.description, "filter_config": query.filter_config, "is_shared": query.is_shared, "created_at": query.created_at.isoformat()} 

16114 except Exception as e: 

16115 db.rollback() 

16116 LOGGER.error(f"Failed to save query: {e}") 

16117 raise HTTPException(status_code=400, detail=str(e)) 

16118 finally: 

16119 # Ensure close() always runs even if commit() fails 

16120 try: 

16121 db.commit() # Commit read-only transaction to avoid implicit rollback 

16122 finally: 

16123 db.close() 

16124 

16125 

16126@admin_router.get("/observability/queries", response_model=list) 

16127@require_permission("admin.system_config", allow_admin_bypass=False) 

16128async def list_observability_queries(request: Request, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument 

16129 """List saved observability queries for the current user. 

16130 

16131 Returns user's own queries plus any shared queries. 

16132 

16133 Args: 

16134 request: FastAPI request object 

16135 user: Authenticated user (required by dependency) 

16136 db: Database session for permission checks. 

16137 

16138 Returns: 

16139 list: List of saved query dictionaries 

16140 """ 

16141 db = next(get_db()) 

16142 try: 

16143 user_email = user.email if hasattr(user, "email") else "unknown" 

16144 

16145 # Get user's own queries + shared queries 

16146 queries = ( 

16147 db.query(ObservabilitySavedQuery) 

16148 .filter(or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)) 

16149 .order_by(desc(ObservabilitySavedQuery.created_at)) 

16150 .all() 

16151 ) 

16152 

16153 return [ 

16154 { 

16155 "id": q.id, 

16156 "name": q.name, 

16157 "description": q.description, 

16158 "filter_config": q.filter_config, 

16159 "is_shared": q.is_shared, 

16160 "user_email": q.user_email, 

16161 "created_at": q.created_at.isoformat(), 

16162 "last_used_at": q.last_used_at.isoformat() if q.last_used_at else None, 

16163 "use_count": q.use_count, 

16164 } 

16165 for q in queries 

16166 ] 

16167 finally: 

16168 # Ensure close() always runs even if commit() fails 

16169 try: 

16170 db.commit() # Commit read-only transaction to avoid implicit rollback 

16171 finally: 

16172 db.close() 

16173 

16174 

16175@admin_router.get("/observability/queries/{query_id}", response_model=dict) 

16176@require_permission("admin.system_config", allow_admin_bypass=False) 

16177async def get_observability_query(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument 

16178 """Get a specific saved query by ID. 

16179 

16180 Args: 

16181 request: FastAPI request object 

16182 query_id: ID of the saved query 

16183 user: Authenticated user (required by dependency) 

16184 db: Database session for permission checks. 

16185 

16186 Returns: 

16187 dict: Query details 

16188 

16189 Raises: 

16190 HTTPException: 404 if query not found or unauthorized 

16191 """ 

16192 db = next(get_db()) 

16193 try: 

16194 user_email = user.email if hasattr(user, "email") else "unknown" 

16195 

16196 # Can only access own queries or shared queries 

16197 query = ( 

16198 db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)).first() 

16199 ) 

16200 

16201 if not query: 

16202 raise HTTPException(status_code=404, detail="Query not found or unauthorized") 

16203 

16204 return { 

16205 "id": query.id, 

16206 "name": query.name, 

16207 "description": query.description, 

16208 "filter_config": query.filter_config, 

16209 "is_shared": query.is_shared, 

16210 "user_email": query.user_email, 

16211 "created_at": query.created_at.isoformat(), 

16212 "last_used_at": query.last_used_at.isoformat() if query.last_used_at else None, 

16213 "use_count": query.use_count, 

16214 } 

16215 finally: 

16216 # Ensure close() always runs even if commit() fails 

16217 try: 

16218 db.commit() # Commit read-only transaction to avoid implicit rollback 

16219 finally: 

16220 db.close() 

16221 

16222 

16223@admin_router.put("/observability/queries/{query_id}", response_model=dict) 

16224@require_permission("admin.system_config", allow_admin_bypass=False) 

16225async def update_observability_query( 

16226 request: Request, # pylint: disable=unused-argument 

16227 query_id: int, 

16228 name: Optional[str] = Body(None), 

16229 description: Optional[str] = Body(None), 

16230 filter_config: Optional[dict] = Body(None), 

16231 is_shared: Optional[bool] = Body(None), 

16232 user=Depends(get_current_user_with_permissions), 

16233 db: Session = Depends(get_db), 

16234): 

16235 """Update an existing saved query. 

16236 

16237 Args: 

16238 request: FastAPI request object 

16239 query_id: ID of the query to update 

16240 name: New name (optional) 

16241 description: New description (optional) 

16242 filter_config: New filter configuration (optional) 

16243 is_shared: New sharing status (optional) 

16244 user: Authenticated user (required by dependency) 

16245 db: Database session for permission checks. 

16246 

16247 Returns: 

16248 dict: Updated query details 

16249 

16250 Raises: 

16251 HTTPException: 404 if query not found, 403 if unauthorized 

16252 """ 

16253 db = next(get_db()) 

16254 try: 

16255 user_email = user.email if hasattr(user, "email") else "unknown" 

16256 

16257 # Can only update own queries 

16258 query = db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, ObservabilitySavedQuery.user_email == user_email).first() 

16259 

16260 if not query: 

16261 raise HTTPException(status_code=404, detail="Query not found or unauthorized") 

16262 

16263 # Update fields if provided 

16264 if name is not None: 

16265 query.name = name 

16266 if description is not None: 

16267 query.description = description 

16268 if filter_config is not None: 

16269 query.filter_config = filter_config 

16270 if is_shared is not None: 

16271 query.is_shared = is_shared 

16272 

16273 db.commit() 

16274 db.refresh(query) 

16275 

16276 return { 

16277 "id": query.id, 

16278 "name": query.name, 

16279 "description": query.description, 

16280 "filter_config": query.filter_config, 

16281 "is_shared": query.is_shared, 

16282 "updated_at": query.updated_at.isoformat(), 

16283 } 

16284 except HTTPException: 

16285 raise 

16286 except Exception as e: 

16287 db.rollback() 

16288 LOGGER.error(f"Failed to update query: {e}") 

16289 raise HTTPException(status_code=400, detail=str(e)) 

16290 finally: 

16291 # Ensure close() always runs even if commit() fails 

16292 try: 

16293 db.commit() # Commit read-only transaction to avoid implicit rollback 

16294 finally: 

16295 db.close() 

16296 

16297 

16298@admin_router.delete("/observability/queries/{query_id}", status_code=204) 

16299@require_permission("admin.system_config", allow_admin_bypass=False) 

16300async def delete_observability_query(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument 

16301 """Delete a saved query. 

16302 

16303 Args: 

16304 request: FastAPI request object 

16305 query_id: ID of the query to delete 

16306 user: Authenticated user (required by dependency) 

16307 db: Database session for permission checks. 

16308 

16309 Raises: 

16310 HTTPException: 404 if query not found, 403 if unauthorized 

16311 """ 

16312 db = next(get_db()) 

16313 try: 

16314 user_email = user.email if hasattr(user, "email") else "unknown" 

16315 

16316 # Can only delete own queries 

16317 query = db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, ObservabilitySavedQuery.user_email == user_email).first() 

16318 

16319 if not query: 

16320 raise HTTPException(status_code=404, detail="Query not found or unauthorized") 

16321 

16322 db.delete(query) 

16323 db.commit() 

16324 finally: 

16325 # Ensure close() always runs even if commit() fails 

16326 try: 

16327 db.commit() # Commit read-only transaction to avoid implicit rollback 

16328 finally: 

16329 db.close() 

16330 

16331 

16332@admin_router.post("/observability/queries/{query_id}/use", response_model=dict) 

16333@require_permission("admin.system_config", allow_admin_bypass=False) 

16334async def track_query_usage(request: Request, query_id: int, user=Depends(get_current_user_with_permissions), db: Session = Depends(get_db)): # pylint: disable=unused-argument 

16335 """Track usage of a saved query (increments use count and updates last_used_at). 

16336 

16337 Args: 

16338 request: FastAPI request object 

16339 query_id: ID of the query being used 

16340 user: Authenticated user (required by dependency) 

16341 db: Database session for permission checks. 

16342 

16343 Returns: 

16344 dict: Updated query usage stats 

16345 

16346 Raises: 

16347 HTTPException: 404 if query not found or unauthorized 

16348 """ 

16349 db = next(get_db()) 

16350 try: 

16351 user_email = user.email if hasattr(user, "email") else "unknown" 

16352 

16353 # Can track usage for own queries or shared queries 

16354 query = ( 

16355 db.query(ObservabilitySavedQuery).filter(ObservabilitySavedQuery.id == query_id, or_(ObservabilitySavedQuery.user_email == user_email, ObservabilitySavedQuery.is_shared is True)).first() 

16356 ) 

16357 

16358 if not query: 

16359 raise HTTPException(status_code=404, detail="Query not found or unauthorized") 

16360 

16361 # Update usage tracking 

16362 query.use_count += 1 

16363 query.last_used_at = utc_now() 

16364 

16365 db.commit() 

16366 db.refresh(query) 

16367 

16368 return {"use_count": query.use_count, "last_used_at": query.last_used_at.isoformat()} 

16369 except HTTPException: 

16370 raise 

16371 except Exception as e: 

16372 db.rollback() 

16373 LOGGER.error(f"Failed to track query usage: {e}") 

16374 raise HTTPException(status_code=400, detail=str(e)) 

16375 finally: 

16376 # Ensure close() always runs even if commit() fails 

16377 try: 

16378 db.commit() # Commit read-only transaction to avoid implicit rollback 

16379 finally: 

16380 db.close() 

16381 

16382 

16383@admin_router.get("/observability/metrics/percentiles", response_model=dict) 

16384@require_permission("admin.system_config", allow_admin_bypass=False) 

16385async def get_latency_percentiles( 

16386 request: Request, # pylint: disable=unused-argument 

16387 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

16388 interval_minutes: int = Query(60, ge=5, le=1440, description="Aggregation interval in minutes"), 

16389 _user=Depends(get_current_user_with_permissions), 

16390 db: Session = Depends(get_db), 

16391): 

16392 """Get latency percentiles (p50, p90, p95, p99) over time. 

16393 

16394 Args: 

16395 request: FastAPI request object 

16396 hours: Number of hours to look back (1-168) 

16397 interval_minutes: Aggregation interval in minutes (5-1440) 

16398 _user: Authenticated user (required by dependency) 

16399 db: Database session for permission checks. 

16400 

16401 Returns: 

16402 dict: Time-series data with percentiles 

16403 

16404 Raises: 

16405 HTTPException: 500 if calculation fails 

16406 """ 

16407 db = next(get_db()) 

16408 try: 

16409 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

16410 

16411 # Use SQL aggregation for PostgreSQL, Python fallback for SQLite 

16412 dialect_name = db.get_bind().dialect.name 

16413 if dialect_name == "postgresql": 

16414 return _get_latency_percentiles_postgresql(db, cutoff_time, interval_minutes) 

16415 return _get_latency_percentiles_python(db, cutoff_time, interval_minutes) 

16416 except Exception as e: 

16417 LOGGER.error(f"Failed to calculate latency percentiles: {e}") 

16418 raise HTTPException(status_code=500, detail=str(e)) 

16419 finally: 

16420 # Ensure close() always runs even if commit() fails 

16421 try: 

16422 db.commit() # Commit read-only transaction to avoid implicit rollback 

16423 finally: 

16424 db.close() 

16425 

16426 

16427def _get_latency_percentiles_postgresql(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict: 

16428 """Compute time-bucketed latency percentiles using PostgreSQL. 

16429 

16430 Args: 

16431 db: Database session 

16432 cutoff_time: Start time for analysis 

16433 interval_minutes: Bucket size in minutes 

16434 

16435 Returns: 

16436 dict: Time-series percentile data 

16437 """ 

16438 # PostgreSQL query with epoch-based bucketing (works for any interval including > 60 min) 

16439 stats_sql = text( 

16440 """ 

16441 SELECT 

16442 TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM start_time) / :interval_seconds) * :interval_seconds) as bucket, 

16443 percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms) as p50, 

16444 percentile_cont(0.90) WITHIN GROUP (ORDER BY duration_ms) as p90, 

16445 percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) as p95, 

16446 percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms) as p99 

16447 FROM observability_traces 

16448 WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL 

16449 GROUP BY bucket 

16450 ORDER BY bucket 

16451 """ 

16452 ) 

16453 

16454 interval_seconds = interval_minutes * 60 

16455 results = db.execute(stats_sql, {"cutoff_time": cutoff_time, "interval_seconds": interval_seconds}).fetchall() 

16456 

16457 if not results: 

16458 return {"timestamps": [], "p50": [], "p90": [], "p95": [], "p99": []} 

16459 

16460 timestamps = [] 

16461 p50_values = [] 

16462 p90_values = [] 

16463 p95_values = [] 

16464 p99_values = [] 

16465 

16466 for row in results: 

16467 timestamps.append(row.bucket.isoformat() if row.bucket else "") 

16468 p50_values.append(round(float(row.p50), 2) if row.p50 else 0) 

16469 p90_values.append(round(float(row.p90), 2) if row.p90 else 0) 

16470 p95_values.append(round(float(row.p95), 2) if row.p95 else 0) 

16471 p99_values.append(round(float(row.p99), 2) if row.p99 else 0) 

16472 

16473 return {"timestamps": timestamps, "p50": p50_values, "p90": p90_values, "p95": p95_values, "p99": p99_values} 

16474 

16475 

16476def _get_latency_percentiles_python(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict: 

16477 """Compute time-bucketed latency percentiles using Python (fallback for SQLite). 

16478 

16479 Args: 

16480 db: Database session 

16481 cutoff_time: Start time for analysis 

16482 interval_minutes: Bucket size in minutes 

16483 

16484 Returns: 

16485 dict: Time-series percentile data 

16486 """ 

16487 # Query all traces with duration in time range 

16488 traces = ( 

16489 db.query(ObservabilityTrace.start_time, ObservabilityTrace.duration_ms) 

16490 .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None)) 

16491 .order_by(ObservabilityTrace.start_time) 

16492 .all() 

16493 ) 

16494 

16495 if not traces: 

16496 return {"timestamps": [], "p50": [], "p90": [], "p95": [], "p99": []} 

16497 

16498 # Group traces into time buckets using epoch-based bucketing (works for any interval) 

16499 interval_seconds = interval_minutes * 60 

16500 buckets: Dict[datetime, List[float]] = defaultdict(list) 

16501 for trace in traces: 

16502 trace_time = trace.start_time 

16503 if trace_time.tzinfo is None: 

16504 trace_time = trace_time.replace(tzinfo=timezone.utc) 

16505 epoch = trace_time.timestamp() 

16506 bucket_epoch = (epoch // interval_seconds) * interval_seconds 

16507 bucket_time = datetime.fromtimestamp(bucket_epoch, tz=timezone.utc) 

16508 buckets[bucket_time].append(trace.duration_ms) 

16509 

16510 # Calculate percentiles for each bucket 

16511 timestamps = [] 

16512 p50_values = [] 

16513 p90_values = [] 

16514 p95_values = [] 

16515 p99_values = [] 

16516 

16517 def percentile_cont(data: List[float], p: float) -> float: 

16518 """Linear interpolation percentile matching PostgreSQL percentile_cont. 

16519 

16520 Args: 

16521 data: Sorted list of float values. 

16522 p: Percentile value between 0 and 1. 

16523 

16524 Returns: 

16525 float: Interpolated percentile value. 

16526 """ 

16527 n = len(data) 

16528 k = p * (n - 1) 

16529 f = int(k) 

16530 c = k - f 

16531 next_i = min(f + 1, n - 1) 

16532 return data[f] + c * (data[next_i] - data[f]) 

16533 

16534 for bucket_time in sorted(buckets.keys()): 

16535 durations = sorted(buckets[bucket_time]) 

16536 

16537 if durations: 

16538 timestamps.append(bucket_time.isoformat()) 

16539 p50_values.append(round(percentile_cont(durations, 0.50), 2)) 

16540 p90_values.append(round(percentile_cont(durations, 0.90), 2)) 

16541 p95_values.append(round(percentile_cont(durations, 0.95), 2)) 

16542 p99_values.append(round(percentile_cont(durations, 0.99), 2)) 

16543 

16544 return {"timestamps": timestamps, "p50": p50_values, "p90": p90_values, "p95": p95_values, "p99": p99_values} 

16545 

16546 

16547@admin_router.get("/observability/metrics/timeseries", response_model=dict) 

16548@require_permission("admin.system_config", allow_admin_bypass=False) 

16549async def get_timeseries_metrics( 

16550 request: Request, # pylint: disable=unused-argument 

16551 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

16552 interval_minutes: int = Query(60, ge=5, le=1440, description="Aggregation interval in minutes"), 

16553 _user=Depends(get_current_user_with_permissions), 

16554 db: Session = Depends(get_db), 

16555): 

16556 """Get time-series metrics (request rate, error rate, throughput). 

16557 

16558 Args: 

16559 request: FastAPI request object 

16560 hours: Number of hours to look back (1-168) 

16561 interval_minutes: Aggregation interval in minutes (5-1440) 

16562 _user: Authenticated user (required by dependency) 

16563 db: Database session for permission checks. 

16564 

16565 Returns: 

16566 dict: Time-series data with request counts, error rates, and throughput 

16567 

16568 Raises: 

16569 HTTPException: 500 if calculation fails 

16570 """ 

16571 db = next(get_db()) 

16572 try: 

16573 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

16574 

16575 # Use SQL aggregation for PostgreSQL, Python fallback for SQLite 

16576 dialect_name = db.get_bind().dialect.name 

16577 if dialect_name == "postgresql": 

16578 return _get_timeseries_metrics_postgresql(db, cutoff_time, interval_minutes) 

16579 return _get_timeseries_metrics_python(db, cutoff_time, interval_minutes) 

16580 except Exception as e: 

16581 LOGGER.error(f"Failed to calculate timeseries metrics: {e}") 

16582 raise HTTPException(status_code=500, detail=str(e)) 

16583 finally: 

16584 # Ensure close() always runs even if commit() fails 

16585 try: 

16586 db.commit() # Commit read-only transaction to avoid implicit rollback 

16587 finally: 

16588 db.close() 

16589 

16590 

16591def _get_timeseries_metrics_postgresql(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict: 

16592 """Compute time-series metrics using PostgreSQL. 

16593 

16594 Args: 

16595 db: Database session 

16596 cutoff_time: Start time for analysis 

16597 interval_minutes: Bucket size in minutes 

16598 

16599 Returns: 

16600 dict: Time-series metrics data 

16601 """ 

16602 # Use epoch-based bucketing (works for any interval including > 60 min) 

16603 stats_sql = text( 

16604 """ 

16605 SELECT 

16606 TO_TIMESTAMP(FLOOR(EXTRACT(EPOCH FROM start_time) / :interval_seconds) * :interval_seconds) as bucket, 

16607 COUNT(*) as total, 

16608 SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as success, 

16609 SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error 

16610 FROM observability_traces 

16611 WHERE start_time >= :cutoff_time 

16612 GROUP BY bucket 

16613 ORDER BY bucket 

16614 """ 

16615 ) 

16616 

16617 interval_seconds = interval_minutes * 60 

16618 results = db.execute(stats_sql, {"cutoff_time": cutoff_time, "interval_seconds": interval_seconds}).fetchall() 

16619 

16620 if not results: 

16621 return {"timestamps": [], "request_count": [], "success_count": [], "error_count": [], "error_rate": []} 

16622 

16623 timestamps = [] 

16624 request_counts = [] 

16625 success_counts = [] 

16626 error_counts = [] 

16627 error_rates = [] 

16628 

16629 for row in results: 

16630 total = row.total or 0 

16631 error = row.error or 0 

16632 error_rate = (error / total * 100) if total > 0 else 0 

16633 

16634 timestamps.append(row.bucket.isoformat() if row.bucket else "") 

16635 request_counts.append(total) 

16636 success_counts.append(row.success or 0) 

16637 error_counts.append(error) 

16638 error_rates.append(round(error_rate, 2)) 

16639 

16640 return { 

16641 "timestamps": timestamps, 

16642 "request_count": request_counts, 

16643 "success_count": success_counts, 

16644 "error_count": error_counts, 

16645 "error_rate": error_rates, 

16646 } 

16647 

16648 

16649def _get_timeseries_metrics_python(db: Session, cutoff_time: datetime, interval_minutes: int) -> dict: 

16650 """Compute time-series metrics using Python (fallback for SQLite). 

16651 

16652 Args: 

16653 db: Database session 

16654 cutoff_time: Start time for analysis 

16655 interval_minutes: Bucket size in minutes 

16656 

16657 Returns: 

16658 dict: Time-series metrics data 

16659 """ 

16660 # Query traces grouped by time bucket 

16661 traces = db.query(ObservabilityTrace.start_time, ObservabilityTrace.status).filter(ObservabilityTrace.start_time >= cutoff_time).order_by(ObservabilityTrace.start_time).all() 

16662 

16663 if not traces: 

16664 return {"timestamps": [], "request_count": [], "success_count": [], "error_count": [], "error_rate": []} 

16665 

16666 # Group traces into time buckets using epoch-based bucketing (works for any interval) 

16667 interval_seconds = interval_minutes * 60 

16668 buckets: Dict[datetime, Dict[str, int]] = defaultdict(lambda: {"total": 0, "success": 0, "error": 0}) 

16669 for trace in traces: 

16670 trace_time = trace.start_time 

16671 if trace_time.tzinfo is None: 

16672 trace_time = trace_time.replace(tzinfo=timezone.utc) 

16673 epoch = trace_time.timestamp() 

16674 bucket_epoch = (epoch // interval_seconds) * interval_seconds 

16675 bucket_time = datetime.fromtimestamp(bucket_epoch, tz=timezone.utc) 

16676 

16677 buckets[bucket_time]["total"] += 1 

16678 if trace.status == "ok": 

16679 buckets[bucket_time]["success"] += 1 

16680 elif trace.status == "error": 

16681 buckets[bucket_time]["error"] += 1 

16682 

16683 # Build time-series arrays 

16684 timestamps = [] 

16685 request_counts = [] 

16686 success_counts = [] 

16687 error_counts = [] 

16688 error_rates = [] 

16689 

16690 for bucket_time in sorted(buckets.keys()): 

16691 bucket = buckets[bucket_time] 

16692 error_rate = (bucket["error"] / bucket["total"] * 100) if bucket["total"] > 0 else 0 

16693 

16694 timestamps.append(bucket_time.isoformat()) 

16695 request_counts.append(bucket["total"]) 

16696 success_counts.append(bucket["success"]) 

16697 error_counts.append(bucket["error"]) 

16698 error_rates.append(round(error_rate, 2)) 

16699 

16700 return { 

16701 "timestamps": timestamps, 

16702 "request_count": request_counts, 

16703 "success_count": success_counts, 

16704 "error_count": error_counts, 

16705 "error_rate": error_rates, 

16706 } 

16707 

16708 

16709def _get_latency_heatmap_postgresql(db: Session, cutoff_time: datetime, hours: int, time_buckets: int, latency_buckets: int) -> dict: 

16710 """Compute latency heatmap using PostgreSQL (optimized path). 

16711 

16712 Uses SQL arithmetic for efficient 2D histogram computation. 

16713 

16714 Args: 

16715 db: Database session 

16716 cutoff_time: Start time for analysis 

16717 hours: Time range in hours 

16718 time_buckets: Number of time buckets 

16719 latency_buckets: Number of latency buckets 

16720 

16721 Returns: 

16722 dict: Heatmap data with time and latency dimensions 

16723 """ 

16724 # First, get min/max durations 

16725 stats_query = text( 

16726 """ 

16727 SELECT MIN(duration_ms) as min_d, MAX(duration_ms) as max_d 

16728 FROM observability_traces 

16729 WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL 

16730 """ 

16731 ) 

16732 stats_row = db.execute(stats_query, {"cutoff_time": cutoff_time}).fetchone() 

16733 

16734 if not stats_row or stats_row.min_d is None: 

16735 return {"time_labels": [], "latency_labels": [], "data": []} 

16736 

16737 min_duration = float(stats_row.min_d) 

16738 max_duration = float(stats_row.max_d) 

16739 latency_range = max_duration - min_duration 

16740 

16741 # Handle case where all durations are the same 

16742 if latency_range == 0: 

16743 latency_range = 1.0 

16744 

16745 time_range_minutes = hours * 60 

16746 latency_bucket_size = latency_range / latency_buckets 

16747 time_bucket_minutes = time_range_minutes / time_buckets 

16748 

16749 # Use SQL arithmetic for 2D histogram bucketing 

16750 heatmap_query = text( 

16751 """ 

16752 SELECT 

16753 LEAST(GREATEST( 

16754 (EXTRACT(EPOCH FROM (start_time - :cutoff_time)) / 60.0 / :time_bucket_minutes)::int, 

16755 0 

16756 ), :time_buckets - 1) as time_idx, 

16757 LEAST(GREATEST( 

16758 ((duration_ms - :min_duration) / :latency_bucket_size)::int, 

16759 0 

16760 ), :latency_buckets - 1) as latency_idx, 

16761 COUNT(*) as cnt 

16762 FROM observability_traces 

16763 WHERE start_time >= :cutoff_time AND duration_ms IS NOT NULL 

16764 GROUP BY time_idx, latency_idx 

16765 """ 

16766 ) 

16767 

16768 rows = db.execute( 

16769 heatmap_query, 

16770 { 

16771 "cutoff_time": cutoff_time, 

16772 "time_bucket_minutes": time_bucket_minutes, 

16773 "time_buckets": time_buckets, 

16774 "min_duration": min_duration, 

16775 "latency_bucket_size": latency_bucket_size, 

16776 "latency_buckets": latency_buckets, 

16777 }, 

16778 ).fetchall() 

16779 

16780 # Initialize heatmap matrix 

16781 heatmap = [[0 for _ in range(time_buckets)] for _ in range(latency_buckets)] 

16782 

16783 # Populate from SQL results 

16784 for row in rows: 

16785 time_idx = int(row.time_idx) 

16786 latency_idx = int(row.latency_idx) 

16787 if 0 <= time_idx < time_buckets and 0 <= latency_idx < latency_buckets: 

16788 heatmap[latency_idx][time_idx] = int(row.cnt) 

16789 

16790 # Generate labels 

16791 time_labels = [] 

16792 for i in range(time_buckets): 

16793 bucket_time = cutoff_time + timedelta(minutes=i * time_bucket_minutes) 

16794 time_labels.append(bucket_time.strftime("%H:%M")) 

16795 

16796 latency_labels = [] 

16797 for i in range(latency_buckets): 

16798 bucket_min = min_duration + i * latency_bucket_size 

16799 bucket_max = bucket_min + latency_bucket_size 

16800 latency_labels.append(f"{bucket_min:.0f}-{bucket_max:.0f}ms") 

16801 

16802 return {"time_labels": time_labels, "latency_labels": latency_labels, "data": heatmap} 

16803 

16804 

16805def _get_latency_heatmap_python(db: Session, cutoff_time: datetime, hours: int, time_buckets: int, latency_buckets: int) -> dict: 

16806 """Compute latency heatmap using Python (fallback for SQLite). 

16807 

16808 Args: 

16809 db: Database session 

16810 cutoff_time: Start time for analysis 

16811 hours: Time range in hours 

16812 time_buckets: Number of time buckets 

16813 latency_buckets: Number of latency buckets 

16814 

16815 Returns: 

16816 dict: Heatmap data with time and latency dimensions 

16817 """ 

16818 # Query all traces with duration 

16819 traces = ( 

16820 db.query(ObservabilityTrace.start_time, ObservabilityTrace.duration_ms) 

16821 .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None)) 

16822 .order_by(ObservabilityTrace.start_time) 

16823 .all() 

16824 ) 

16825 

16826 if not traces: 

16827 return {"time_labels": [], "latency_labels": [], "data": []} 

16828 

16829 # Calculate time bucket size 

16830 time_range = hours * 60 # minutes 

16831 time_bucket_minutes = time_range / time_buckets 

16832 

16833 # Find latency range and create buckets 

16834 durations = [t.duration_ms for t in traces] 

16835 min_duration = min(durations) 

16836 max_duration = max(durations) 

16837 latency_range = max_duration - min_duration 

16838 latency_bucket_size = latency_range / latency_buckets if latency_range > 0 else 1 

16839 

16840 # Initialize heatmap matrix 

16841 heatmap = [[0 for _ in range(time_buckets)] for _ in range(latency_buckets)] 

16842 

16843 # Populate heatmap 

16844 for trace in traces: 

16845 trace_time = trace.start_time 

16846 # Convert naive SQLite datetime to UTC aware 

16847 if trace_time.tzinfo is None: 

16848 trace_time = trace_time.replace(tzinfo=timezone.utc) 

16849 

16850 # Calculate time bucket index 

16851 time_diff = (trace_time - cutoff_time).total_seconds() / 60 # minutes 

16852 time_idx = min(int(time_diff / time_bucket_minutes), time_buckets - 1) 

16853 

16854 # Calculate latency bucket index 

16855 latency_idx = min(int((trace.duration_ms - min_duration) / latency_bucket_size), latency_buckets - 1) 

16856 

16857 heatmap[latency_idx][time_idx] += 1 

16858 

16859 # Generate labels 

16860 time_labels = [] 

16861 for i in range(time_buckets): 

16862 bucket_time = cutoff_time + timedelta(minutes=i * time_bucket_minutes) 

16863 time_labels.append(bucket_time.strftime("%H:%M")) 

16864 

16865 latency_labels = [] 

16866 for i in range(latency_buckets): 

16867 bucket_min = min_duration + i * latency_bucket_size 

16868 bucket_max = bucket_min + latency_bucket_size 

16869 latency_labels.append(f"{bucket_min:.0f}-{bucket_max:.0f}ms") 

16870 

16871 return {"time_labels": time_labels, "latency_labels": latency_labels, "data": heatmap} 

16872 

16873 

16874@admin_router.get("/observability/metrics/top-slow", response_model=dict) 

16875@require_permission("admin.system_config", allow_admin_bypass=False) 

16876async def get_top_slow_endpoints( 

16877 request: Request, # pylint: disable=unused-argument 

16878 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

16879 limit: int = Query(10, ge=1, le=100, description="Number of results"), 

16880 _user=Depends(get_current_user_with_permissions), 

16881 db: Session = Depends(get_db), 

16882): 

16883 """Get top N slowest endpoints by average duration. 

16884 

16885 Args: 

16886 request: FastAPI request object 

16887 hours: Number of hours to look back (1-168) 

16888 limit: Number of results to return (1-100) 

16889 _user: Authenticated user (required by dependency) 

16890 db: Database session for permission checks. 

16891 

16892 Returns: 

16893 dict: List of slowest endpoints with stats 

16894 

16895 Raises: 

16896 HTTPException: 500 if query fails 

16897 """ 

16898 db = next(get_db()) 

16899 try: 

16900 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

16901 

16902 # Group by endpoint and calculate average duration 

16903 results = ( 

16904 db.query( 

16905 ObservabilityTrace.http_url, 

16906 ObservabilityTrace.http_method, 

16907 func.count(ObservabilityTrace.trace_id).label("count"), # pylint: disable=not-callable 

16908 func.avg(ObservabilityTrace.duration_ms).label("avg_duration"), 

16909 func.max(ObservabilityTrace.duration_ms).label("max_duration"), 

16910 ) 

16911 .filter(ObservabilityTrace.start_time >= cutoff_time, ObservabilityTrace.duration_ms.isnot(None)) 

16912 .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method) 

16913 .order_by(desc("avg_duration")) 

16914 .limit(limit) 

16915 .all() 

16916 ) 

16917 

16918 endpoints = [] 

16919 for row in results: 

16920 endpoints.append( 

16921 { 

16922 "endpoint": f"{row.http_method} {row.http_url}", 

16923 "method": row.http_method, 

16924 "url": row.http_url, 

16925 "count": row.count, 

16926 "avg_duration_ms": round(row.avg_duration, 2), 

16927 "max_duration_ms": round(row.max_duration, 2), 

16928 } 

16929 ) 

16930 

16931 return {"endpoints": endpoints} 

16932 except Exception as e: 

16933 LOGGER.error(f"Failed to get top slow endpoints: {e}") 

16934 raise HTTPException(status_code=500, detail=str(e)) 

16935 finally: 

16936 # Ensure close() always runs even if commit() fails 

16937 try: 

16938 db.commit() # Commit read-only transaction to avoid implicit rollback 

16939 finally: 

16940 db.close() 

16941 

16942 

16943@admin_router.get("/observability/metrics/top-volume", response_model=dict) 

16944@require_permission("admin.system_config", allow_admin_bypass=False) 

16945async def get_top_volume_endpoints( 

16946 request: Request, # pylint: disable=unused-argument 

16947 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

16948 limit: int = Query(10, ge=1, le=100, description="Number of results"), 

16949 _user=Depends(get_current_user_with_permissions), 

16950 db: Session = Depends(get_db), 

16951): 

16952 """Get top N highest volume endpoints by request count. 

16953 

16954 Args: 

16955 request: FastAPI request object 

16956 hours: Number of hours to look back (1-168) 

16957 limit: Number of results to return (1-100) 

16958 _user: Authenticated user (required by dependency) 

16959 db: Database session for permission checks. 

16960 

16961 Returns: 

16962 dict: List of highest volume endpoints with stats 

16963 

16964 Raises: 

16965 HTTPException: 500 if query fails 

16966 """ 

16967 db = next(get_db()) 

16968 try: 

16969 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

16970 

16971 # Group by endpoint and count requests 

16972 results = ( 

16973 db.query( 

16974 ObservabilityTrace.http_url, 

16975 ObservabilityTrace.http_method, 

16976 func.count(ObservabilityTrace.trace_id).label("count"), # pylint: disable=not-callable 

16977 func.avg(ObservabilityTrace.duration_ms).label("avg_duration"), 

16978 ) 

16979 .filter(ObservabilityTrace.start_time >= cutoff_time) 

16980 .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method) 

16981 .order_by(desc("count")) 

16982 .limit(limit) 

16983 .all() 

16984 ) 

16985 

16986 endpoints = [] 

16987 for row in results: 

16988 endpoints.append( 

16989 { 

16990 "endpoint": f"{row.http_method} {row.http_url}", 

16991 "method": row.http_method, 

16992 "url": row.http_url, 

16993 "count": row.count, 

16994 "avg_duration_ms": round(row.avg_duration, 2) if row.avg_duration else 0, 

16995 } 

16996 ) 

16997 

16998 return {"endpoints": endpoints} 

16999 except Exception as e: 

17000 LOGGER.error(f"Failed to get top volume endpoints: {e}") 

17001 raise HTTPException(status_code=500, detail=str(e)) 

17002 finally: 

17003 # Ensure close() always runs even if commit() fails 

17004 try: 

17005 db.commit() # Commit read-only transaction to avoid implicit rollback 

17006 finally: 

17007 db.close() 

17008 

17009 

17010@admin_router.get("/observability/metrics/top-errors", response_model=dict) 

17011@require_permission("admin.system_config", allow_admin_bypass=False) 

17012async def get_top_error_endpoints( 

17013 request: Request, # pylint: disable=unused-argument 

17014 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17015 limit: int = Query(10, ge=1, le=100, description="Number of results"), 

17016 _user=Depends(get_current_user_with_permissions), 

17017 db: Session = Depends(get_db), 

17018): 

17019 """Get top N error-prone endpoints by error count and rate. 

17020 

17021 Args: 

17022 request: FastAPI request object 

17023 hours: Number of hours to look back (1-168) 

17024 limit: Number of results to return (1-100) 

17025 _user: Authenticated user (required by dependency) 

17026 db: Database session for permission checks. 

17027 

17028 Returns: 

17029 dict: List of error-prone endpoints with stats 

17030 

17031 Raises: 

17032 HTTPException: 500 if query fails 

17033 """ 

17034 db = next(get_db()) 

17035 try: 

17036 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17037 

17038 # Group by endpoint and count errors 

17039 results = ( 

17040 db.query( 

17041 ObservabilityTrace.http_url, 

17042 ObservabilityTrace.http_method, 

17043 func.count(ObservabilityTrace.trace_id).label("total_count"), # pylint: disable=not-callable 

17044 func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)).label("error_count"), 

17045 ) 

17046 .filter(ObservabilityTrace.start_time >= cutoff_time) 

17047 .group_by(ObservabilityTrace.http_url, ObservabilityTrace.http_method) 

17048 .having(func.sum(case((ObservabilityTrace.status == "error", 1), else_=0)) > 0) 

17049 .order_by(desc("error_count")) 

17050 .limit(limit) 

17051 .all() 

17052 ) 

17053 

17054 endpoints = [] 

17055 for row in results: 

17056 error_rate = (row.error_count / row.total_count * 100) if row.total_count > 0 else 0 

17057 endpoints.append( 

17058 { 

17059 "endpoint": f"{row.http_method} {row.http_url}", 

17060 "method": row.http_method, 

17061 "url": row.http_url, 

17062 "total_count": row.total_count, 

17063 "error_count": row.error_count, 

17064 "error_rate": round(error_rate, 2), 

17065 } 

17066 ) 

17067 

17068 return {"endpoints": endpoints} 

17069 except Exception as e: 

17070 LOGGER.error(f"Failed to get top error endpoints: {e}") 

17071 raise HTTPException(status_code=500, detail=str(e)) 

17072 finally: 

17073 # Ensure close() always runs even if commit() fails 

17074 try: 

17075 db.commit() # Commit read-only transaction to avoid implicit rollback 

17076 finally: 

17077 db.close() 

17078 

17079 

17080@admin_router.get("/observability/metrics/heatmap", response_model=dict) 

17081@require_permission("admin.system_config", allow_admin_bypass=False) 

17082async def get_latency_heatmap( 

17083 request: Request, # pylint: disable=unused-argument 

17084 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17085 time_buckets: int = Query(24, ge=10, le=100, description="Number of time buckets"), 

17086 latency_buckets: int = Query(20, ge=5, le=50, description="Number of latency buckets"), 

17087 _user=Depends(get_current_user_with_permissions), 

17088 db: Session = Depends(get_db), 

17089): 

17090 """Get latency distribution heatmap data. 

17091 

17092 Uses PostgreSQL SQL aggregation for efficient computation when available, 

17093 falls back to Python for SQLite. 

17094 

17095 Args: 

17096 request: FastAPI request object 

17097 hours: Number of hours to look back (1-168) 

17098 time_buckets: Number of time buckets (10-100) 

17099 latency_buckets: Number of latency buckets (5-50) 

17100 _user: Authenticated user (required by dependency) 

17101 db: Database session for permission checks. 

17102 

17103 Returns: 

17104 dict: Heatmap data with time and latency dimensions 

17105 

17106 Raises: 

17107 HTTPException: 500 if calculation fails 

17108 """ 

17109 db = next(get_db()) 

17110 try: 

17111 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17112 

17113 # Route to appropriate implementation based on database dialect 

17114 dialect_name = db.get_bind().dialect.name 

17115 if dialect_name == "postgresql": 

17116 return _get_latency_heatmap_postgresql(db, cutoff_time, hours, time_buckets, latency_buckets) 

17117 return _get_latency_heatmap_python(db, cutoff_time, hours, time_buckets, latency_buckets) 

17118 except Exception as e: 

17119 LOGGER.error(f"Failed to generate latency heatmap: {e}") 

17120 raise HTTPException(status_code=500, detail=str(e)) 

17121 finally: 

17122 # Ensure close() always runs even if commit() fails 

17123 try: 

17124 db.commit() # Commit read-only transaction to avoid implicit rollback 

17125 finally: 

17126 db.close() 

17127 

17128 

17129@admin_router.get("/observability/tools/usage", response_model=dict) 

17130@require_permission("admin.system_config", allow_admin_bypass=False) 

17131async def get_tool_usage( 

17132 request: Request, # pylint: disable=unused-argument 

17133 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17134 limit: int = Query(20, ge=5, le=100, description="Number of tools to return"), 

17135 _user=Depends(get_current_user_with_permissions), 

17136 db: Session = Depends(get_db), 

17137): 

17138 """Get tool usage frequency statistics. 

17139 

17140 Args: 

17141 request: FastAPI request object 

17142 hours: Number of hours to look back (1-168) 

17143 limit: Maximum number of tools to return (5-100) 

17144 _user: Authenticated user (required by dependency) 

17145 db: Database session for permission checks. 

17146 

17147 Returns: 

17148 dict: Tool usage statistics with counts and percentages 

17149 

17150 Raises: 

17151 HTTPException: 500 if calculation fails 

17152 """ 

17153 db = next(get_db()) 

17154 try: 

17155 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17156 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17157 dialect_name = db.get_bind().dialect.name 

17158 

17159 # Query tool invocations from spans 

17160 # Note: Using $."tool.name" because the JSON key contains a dot 

17161 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17162 tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name) 

17163 tool_usage = ( 

17164 db.query( 

17165 tool_name_expr.label("tool_name"), 

17166 func.count(ObservabilitySpan.span_id).label("count"), # pylint: disable=not-callable 

17167 ) 

17168 .filter( 

17169 ObservabilitySpan.name == "tool.invoke", 

17170 ObservabilitySpan.start_time >= cutoff_time_naive, 

17171 tool_name_expr.isnot(None), 

17172 ) 

17173 .group_by(tool_name_expr) 

17174 .order_by(func.count(ObservabilitySpan.span_id).desc()) # pylint: disable=not-callable 

17175 .limit(limit) 

17176 .all() 

17177 ) 

17178 

17179 total_invocations = sum(row.count for row in tool_usage) 

17180 

17181 tools = [ 

17182 { 

17183 "tool_name": row.tool_name, 

17184 "count": row.count, 

17185 "percentage": round((row.count / total_invocations * 100) if total_invocations > 0 else 0, 2), 

17186 } 

17187 for row in tool_usage 

17188 ] 

17189 

17190 return {"tools": tools, "total_invocations": total_invocations, "time_range_hours": hours} 

17191 except Exception as e: 

17192 LOGGER.error(f"Failed to get tool usage statistics: {e}") 

17193 raise HTTPException(status_code=500, detail=str(e)) 

17194 finally: 

17195 # Ensure close() always runs even if commit() fails 

17196 try: 

17197 db.commit() # Commit read-only transaction to avoid implicit rollback 

17198 finally: 

17199 db.close() 

17200 

17201 

17202@admin_router.get("/observability/tools/performance", response_model=dict) 

17203@require_permission("admin.system_config", allow_admin_bypass=False) 

17204async def get_tool_performance( 

17205 request: Request, # pylint: disable=unused-argument 

17206 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17207 limit: int = Query(20, ge=5, le=100, description="Number of tools to return"), 

17208 _user=Depends(get_current_user_with_permissions), 

17209 db: Session = Depends(get_db), 

17210): 

17211 """Get tool performance metrics (avg, min, max duration). 

17212 

17213 Args: 

17214 request: FastAPI request object 

17215 hours: Number of hours to look back (1-168) 

17216 limit: Maximum number of tools to return (5-100) 

17217 _user: Authenticated user (required by dependency) 

17218 db: Database session for permission checks. 

17219 

17220 Returns: 

17221 dict: Tool performance metrics 

17222 

17223 Raises: 

17224 HTTPException: 500 if calculation fails 

17225 """ 

17226 db = next(get_db()) 

17227 try: 

17228 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17229 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17230 

17231 # Use shared helper to compute performance grouped by the JSON attribute 

17232 tools = _get_span_entity_performance( 

17233 db=db, 

17234 cutoff_time=cutoff_time, 

17235 cutoff_time_naive=cutoff_time_naive, 

17236 span_names=["tool.invoke"], 

17237 json_key="tool.name", 

17238 result_key="tool_name", 

17239 limit=limit, 

17240 ) 

17241 

17242 return {"tools": tools, "time_range_hours": hours} 

17243 except Exception as e: 

17244 LOGGER.error(f"Failed to get tool performance metrics: {e}") 

17245 raise HTTPException(status_code=500, detail=str(e)) 

17246 finally: 

17247 # Ensure close() always runs even if commit() fails 

17248 try: 

17249 db.commit() # Commit read-only transaction to avoid implicit rollback 

17250 finally: 

17251 db.close() 

17252 

17253 

17254@admin_router.get("/observability/tools/errors", response_model=dict) 

17255@require_permission("admin.system_config", allow_admin_bypass=False) 

17256async def get_tool_errors( 

17257 request: Request, # pylint: disable=unused-argument 

17258 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17259 limit: int = Query(20, ge=5, le=100, description="Number of tools to return"), 

17260 _user=Depends(get_current_user_with_permissions), 

17261 db: Session = Depends(get_db), 

17262): 

17263 """Get tool error rates and statistics. 

17264 

17265 Args: 

17266 request: FastAPI request object 

17267 hours: Number of hours to look back (1-168) 

17268 limit: Maximum number of tools to return (5-100) 

17269 _user: Authenticated user (required by dependency) 

17270 db: Database session for permission checks. 

17271 

17272 Returns: 

17273 dict: Tool error statistics 

17274 

17275 Raises: 

17276 HTTPException: 500 if calculation fails 

17277 """ 

17278 db = next(get_db()) 

17279 try: 

17280 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17281 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17282 dialect_name = db.get_bind().dialect.name 

17283 

17284 # Query tool error rates 

17285 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17286 tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name) 

17287 tool_errors = ( 

17288 db.query( 

17289 tool_name_expr.label("tool_name"), 

17290 func.count(ObservabilitySpan.span_id).label("total_count"), # pylint: disable=not-callable 

17291 func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"), # pylint: disable=not-callable 

17292 ) 

17293 .filter( 

17294 ObservabilitySpan.name == "tool.invoke", 

17295 ObservabilitySpan.start_time >= cutoff_time_naive, 

17296 tool_name_expr.isnot(None), 

17297 ) 

17298 .group_by(tool_name_expr) 

17299 .order_by(func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).desc()) # pylint: disable=not-callable 

17300 .limit(limit) 

17301 .all() 

17302 ) 

17303 

17304 tools = [ 

17305 { 

17306 "tool_name": row.tool_name, 

17307 "total_count": row.total_count, 

17308 "error_count": row.error_count or 0, 

17309 "error_rate": round((row.error_count / row.total_count * 100) if row.total_count > 0 and row.error_count else 0, 2), 

17310 } 

17311 for row in tool_errors 

17312 ] 

17313 

17314 return {"tools": tools, "time_range_hours": hours} 

17315 except Exception as e: 

17316 LOGGER.error(f"Failed to get tool error statistics: {e}") 

17317 raise HTTPException(status_code=500, detail=str(e)) 

17318 finally: 

17319 # Ensure close() always runs even if commit() fails 

17320 try: 

17321 db.commit() # Commit read-only transaction to avoid implicit rollback 

17322 finally: 

17323 db.close() 

17324 

17325 

17326@admin_router.get("/observability/tools/chains", response_model=dict) 

17327@require_permission("admin.system_config", allow_admin_bypass=False) 

17328async def get_tool_chains( 

17329 request: Request, # pylint: disable=unused-argument 

17330 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17331 limit: int = Query(20, ge=5, le=100, description="Number of chains to return"), 

17332 _user=Depends(get_current_user_with_permissions), 

17333 db: Session = Depends(get_db), 

17334): 

17335 """Get tool chain analysis (which tools are invoked together in the same trace). 

17336 

17337 Args: 

17338 request: FastAPI request object 

17339 hours: Number of hours to look back (1-168) 

17340 limit: Maximum number of chains to return (5-100) 

17341 _user: Authenticated user (required by dependency) 

17342 db: Database session for permission checks. 

17343 

17344 Returns: 

17345 dict: Tool chain statistics showing common tool sequences 

17346 

17347 Raises: 

17348 HTTPException: 500 if calculation fails 

17349 """ 

17350 db = next(get_db()) 

17351 try: 

17352 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17353 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17354 dialect_name = db.get_bind().dialect.name 

17355 

17356 # Get all tool invocations grouped by trace_id 

17357 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17358 tool_name_expr = extract_json_field(ObservabilitySpan.attributes, '$."tool.name"', dialect_name=dialect_name) 

17359 tool_spans = ( 

17360 db.query( 

17361 ObservabilitySpan.trace_id, 

17362 tool_name_expr.label("tool_name"), 

17363 ObservabilitySpan.start_time, 

17364 ) 

17365 .filter( 

17366 ObservabilitySpan.name == "tool.invoke", 

17367 ObservabilitySpan.start_time >= cutoff_time_naive, 

17368 tool_name_expr.isnot(None), 

17369 ) 

17370 .order_by(ObservabilitySpan.trace_id, ObservabilitySpan.start_time) 

17371 .all() 

17372 ) 

17373 

17374 # Group tools by trace and create chains 

17375 trace_tools = {} 

17376 for span in tool_spans: 

17377 if span.trace_id not in trace_tools: 

17378 trace_tools[span.trace_id] = [] 

17379 trace_tools[span.trace_id].append(span.tool_name) 

17380 

17381 # Count tool chain frequencies 

17382 chain_counts = {} 

17383 for tools in trace_tools.values(): 

17384 if len(tools) > 1: 

17385 # Create a chain string (sorted to treat [A,B] and [B,A] as same chain) 

17386 chain = " -> ".join(tools) 

17387 chain_counts[chain] = chain_counts.get(chain, 0) + 1 

17388 

17389 # Sort by frequency and take top N 

17390 sorted_chains = sorted(chain_counts.items(), key=lambda x: x[1], reverse=True)[:limit] 

17391 

17392 chains = [{"chain": chain, "count": count} for chain, count in sorted_chains] 

17393 

17394 return {"chains": chains, "total_traces_with_tools": len(trace_tools), "time_range_hours": hours} 

17395 except Exception as e: 

17396 LOGGER.error(f"Failed to get tool chain statistics: {e}") 

17397 raise HTTPException(status_code=500, detail=str(e)) 

17398 finally: 

17399 # Ensure close() always runs even if commit() fails 

17400 try: 

17401 db.commit() # Commit read-only transaction to avoid implicit rollback 

17402 finally: 

17403 db.close() 

17404 

17405 

17406@admin_router.get("/observability/tools/partial", response_class=HTMLResponse) 

17407@require_permission("admin.system_config", allow_admin_bypass=False) 

17408async def get_tools_partial( 

17409 request: Request, 

17410 _user=Depends(get_current_user_with_permissions), 

17411 _db: Session = Depends(get_db), 

17412): 

17413 """Render the tool invocation metrics dashboard HTML partial. 

17414 

17415 Args: 

17416 request: FastAPI request object 

17417 _user: Authenticated user (required by dependency) 

17418 _db: Database session for permission checks. 

17419 

17420 Returns: 

17421 HTMLResponse: Rendered tool metrics dashboard partial 

17422 """ 

17423 root_path = request.scope.get("root_path", "") 

17424 return request.app.state.templates.TemplateResponse( 

17425 request, 

17426 "observability_tools.html", 

17427 { 

17428 "request": request, 

17429 "root_path": root_path, 

17430 }, 

17431 ) 

17432 

17433 

17434# ============================================================================== 

17435# Prompts Observability Endpoints 

17436# ============================================================================== 

17437 

17438 

17439@admin_router.get("/observability/prompts/usage", response_model=dict) 

17440@require_permission("admin.system_config", allow_admin_bypass=False) 

17441async def get_prompt_usage( 

17442 request: Request, # pylint: disable=unused-argument 

17443 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17444 limit: int = Query(20, ge=5, le=100, description="Number of prompts to return"), 

17445 _user=Depends(get_current_user_with_permissions), 

17446 db: Session = Depends(get_db), 

17447): 

17448 """Get prompt rendering frequency statistics. 

17449 

17450 Args: 

17451 request: FastAPI request object 

17452 hours: Number of hours to look back (1-168) 

17453 limit: Maximum number of prompts to return (5-100) 

17454 _user: Authenticated user (required by dependency) 

17455 db: Database session for permission checks. 

17456 

17457 Returns: 

17458 dict: Prompt usage statistics with counts and percentages 

17459 

17460 Raises: 

17461 HTTPException: 500 if calculation fails 

17462 """ 

17463 db = next(get_db()) 

17464 try: 

17465 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17466 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17467 dialect_name = db.get_bind().dialect.name 

17468 

17469 # Query prompt renders from spans (looking for prompts/get calls) 

17470 # The prompt id should be in attributes as "prompt.id" 

17471 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17472 prompt_id_expr = extract_json_field(ObservabilitySpan.attributes, '$."prompt.id"', dialect_name=dialect_name) 

17473 prompt_usage = ( 

17474 db.query( 

17475 prompt_id_expr.label("prompt_id"), 

17476 func.count(ObservabilitySpan.span_id).label("count"), # pylint: disable=not-callable 

17477 ) 

17478 .filter( 

17479 ObservabilitySpan.name.in_(["prompt.get", "prompts.get", "prompt.render"]), 

17480 ObservabilitySpan.start_time >= cutoff_time_naive, 

17481 prompt_id_expr.isnot(None), 

17482 ) 

17483 .group_by(prompt_id_expr) 

17484 .order_by(func.count(ObservabilitySpan.span_id).desc()) # pylint: disable=not-callable 

17485 .limit(limit) 

17486 .all() 

17487 ) 

17488 

17489 total_renders = sum(row.count for row in prompt_usage) 

17490 

17491 prompts = [ 

17492 { 

17493 "prompt_id": row.prompt_id, 

17494 "count": row.count, 

17495 "percentage": round((row.count / total_renders * 100) if total_renders > 0 else 0, 2), 

17496 } 

17497 for row in prompt_usage 

17498 ] 

17499 

17500 return {"prompts": prompts, "total_renders": total_renders, "time_range_hours": hours} 

17501 except Exception as e: 

17502 LOGGER.error(f"Failed to get prompt usage statistics: {e}") 

17503 raise HTTPException(status_code=500, detail=str(e)) 

17504 finally: 

17505 # Ensure close() always runs even if commit() fails 

17506 try: 

17507 db.commit() # Commit read-only transaction to avoid implicit rollback 

17508 finally: 

17509 db.close() 

17510 

17511 

17512@admin_router.get("/observability/prompts/performance", response_model=dict) 

17513@require_permission("admin.system_config", allow_admin_bypass=False) 

17514async def get_prompt_performance( 

17515 request: Request, # pylint: disable=unused-argument 

17516 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17517 limit: int = Query(20, ge=5, le=100, description="Number of prompts to return"), 

17518 _user=Depends(get_current_user_with_permissions), 

17519 db: Session = Depends(get_db), 

17520): 

17521 """Get prompt performance metrics (avg, min, max duration). 

17522 

17523 Args: 

17524 request: FastAPI request object 

17525 hours: Number of hours to look back (1-168) 

17526 limit: Maximum number of prompts to return (5-100) 

17527 _user: Authenticated user (required by dependency) 

17528 db: Database session for permission checks. 

17529 

17530 Returns: 

17531 dict: Prompt performance metrics 

17532 

17533 Raises: 

17534 HTTPException: 500 if calculation fails 

17535 """ 

17536 db = next(get_db()) 

17537 try: 

17538 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17539 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17540 

17541 # Use shared helper to compute performance grouped by the JSON attribute 

17542 prompts = _get_span_entity_performance( 

17543 db=db, 

17544 cutoff_time=cutoff_time, 

17545 cutoff_time_naive=cutoff_time_naive, 

17546 span_names=["prompt.get", "prompts.get", "prompt.render"], 

17547 json_key="prompt.id", 

17548 result_key="prompt_id", 

17549 limit=limit, 

17550 ) 

17551 

17552 return {"prompts": prompts, "time_range_hours": hours} 

17553 except Exception as e: 

17554 LOGGER.error(f"Failed to get prompt performance metrics: {e}") 

17555 raise HTTPException(status_code=500, detail=str(e)) 

17556 finally: 

17557 # Ensure close() always runs even if commit() fails 

17558 try: 

17559 db.commit() # Commit read-only transaction to avoid implicit rollback 

17560 finally: 

17561 db.close() 

17562 

17563 

17564@admin_router.get("/observability/prompts/errors", response_model=dict) 

17565@require_permission("admin.system_config", allow_admin_bypass=False) 

17566async def get_prompts_errors( 

17567 hours: int = Query(24, description="Time range in hours"), 

17568 limit: int = Query(20, description="Maximum number of results"), 

17569 _user=Depends(get_current_user_with_permissions), 

17570 db: Session = Depends(get_db), 

17571): 

17572 """Get prompt error rates. 

17573 

17574 Args: 

17575 hours: Time range in hours to analyze 

17576 limit: Maximum number of prompts to return 

17577 _user: Authenticated user (required by dependency) 

17578 db: Database session for permission checks. 

17579 

17580 Returns: 

17581 dict: Prompt error statistics 

17582 """ 

17583 db = next(get_db()) 

17584 try: 

17585 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17586 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17587 dialect_name = db.get_bind().dialect.name 

17588 

17589 # Get all prompt spans with their status 

17590 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17591 prompt_id_expr = extract_json_field(ObservabilitySpan.attributes, '$."prompt.id"', dialect_name=dialect_name) 

17592 prompt_stats = ( 

17593 db.query( 

17594 prompt_id_expr.label("prompt_id"), 

17595 func.count().label("total_count"), # pylint: disable=not-callable 

17596 func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"), 

17597 ) 

17598 .filter( 

17599 ObservabilitySpan.name == "prompt.render", 

17600 ObservabilitySpan.start_time >= cutoff_time_naive, 

17601 prompt_id_expr.isnot(None), 

17602 ) 

17603 .group_by(prompt_id_expr) 

17604 .all() 

17605 ) 

17606 

17607 prompts_data = [] 

17608 for stat in prompt_stats: 

17609 total = stat.total_count 

17610 errors = stat.error_count or 0 

17611 error_rate = round((errors / total * 100), 2) if total > 0 else 0 

17612 

17613 prompts_data.append({"prompt_id": stat.prompt_id, "total_count": total, "error_count": errors, "error_rate": error_rate}) 

17614 

17615 # Sort by error rate descending 

17616 prompts_data.sort(key=lambda x: x["error_rate"], reverse=True) 

17617 prompts_data = prompts_data[:limit] 

17618 

17619 return {"prompts": prompts_data, "time_range_hours": hours} 

17620 finally: 

17621 # Ensure close() always runs even if commit() fails 

17622 try: 

17623 db.commit() # Commit read-only transaction to avoid implicit rollback 

17624 finally: 

17625 db.close() 

17626 

17627 

17628@admin_router.get("/observability/prompts/partial", response_class=HTMLResponse) 

17629@require_permission("admin.system_config", allow_admin_bypass=False) 

17630async def get_prompts_partial( 

17631 request: Request, 

17632 _user=Depends(get_current_user_with_permissions), 

17633 _db: Session = Depends(get_db), 

17634): 

17635 """Render the prompt rendering metrics dashboard HTML partial. 

17636 

17637 Args: 

17638 request: FastAPI request object 

17639 _user: Authenticated user (required by dependency) 

17640 _db: Database session for permission checks. 

17641 

17642 Returns: 

17643 HTMLResponse: Rendered prompt metrics dashboard partial 

17644 """ 

17645 root_path = request.scope.get("root_path", "") 

17646 return request.app.state.templates.TemplateResponse( 

17647 request, 

17648 "observability_prompts.html", 

17649 { 

17650 "request": request, 

17651 "root_path": root_path, 

17652 }, 

17653 ) 

17654 

17655 

17656# ============================================================================== 

17657# Resources Observability Endpoints 

17658# ============================================================================== 

17659 

17660 

17661@admin_router.get("/observability/resources/usage", response_model=dict) 

17662@require_permission("admin.system_config", allow_admin_bypass=False) 

17663async def get_resource_usage( 

17664 request: Request, # pylint: disable=unused-argument 

17665 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17666 limit: int = Query(20, ge=5, le=100, description="Number of resources to return"), 

17667 _user=Depends(get_current_user_with_permissions), 

17668 db: Session = Depends(get_db), 

17669): 

17670 """Get resource fetch frequency statistics. 

17671 

17672 Args: 

17673 request: FastAPI request object 

17674 hours: Number of hours to look back (1-168) 

17675 limit: Maximum number of resources to return (5-100) 

17676 _user: Authenticated user (required by dependency) 

17677 db: Database session for permission checks. 

17678 

17679 Returns: 

17680 dict: Resource usage statistics with counts and percentages 

17681 

17682 Raises: 

17683 HTTPException: 500 if calculation fails 

17684 """ 

17685 db = next(get_db()) 

17686 try: 

17687 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17688 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17689 dialect_name = db.get_bind().dialect.name 

17690 

17691 # Query resource reads from spans (looking for resources/read calls) 

17692 # The resource URI should be in attributes 

17693 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17694 resource_uri_expr = extract_json_field(ObservabilitySpan.attributes, '$."resource.uri"', dialect_name=dialect_name) 

17695 resource_usage = ( 

17696 db.query( 

17697 resource_uri_expr.label("resource_uri"), 

17698 func.count(ObservabilitySpan.span_id).label("count"), # pylint: disable=not-callable 

17699 ) 

17700 .filter( 

17701 ObservabilitySpan.name.in_(["resource.read", "resources.read", "resource.fetch"]), 

17702 ObservabilitySpan.start_time >= cutoff_time_naive, 

17703 resource_uri_expr.isnot(None), 

17704 ) 

17705 .group_by(resource_uri_expr) 

17706 .order_by(func.count(ObservabilitySpan.span_id).desc()) # pylint: disable=not-callable 

17707 .limit(limit) 

17708 .all() 

17709 ) 

17710 

17711 total_fetches = sum(row.count for row in resource_usage) 

17712 

17713 resources = [ 

17714 { 

17715 "resource_uri": row.resource_uri, 

17716 "count": row.count, 

17717 "percentage": round((row.count / total_fetches * 100) if total_fetches > 0 else 0, 2), 

17718 } 

17719 for row in resource_usage 

17720 ] 

17721 

17722 return {"resources": resources, "total_fetches": total_fetches, "time_range_hours": hours} 

17723 except Exception as e: 

17724 LOGGER.error(f"Failed to get resource usage statistics: {e}") 

17725 raise HTTPException(status_code=500, detail=str(e)) 

17726 finally: 

17727 # Ensure close() always runs even if commit() fails 

17728 try: 

17729 db.commit() # Commit read-only transaction to avoid implicit rollback 

17730 finally: 

17731 db.close() 

17732 

17733 

17734@admin_router.get("/observability/resources/performance", response_model=dict) 

17735@require_permission("admin.system_config", allow_admin_bypass=False) 

17736async def get_resource_performance( 

17737 request: Request, # pylint: disable=unused-argument 

17738 hours: int = Query(24, ge=1, le=168, description="Time range in hours"), 

17739 limit: int = Query(20, ge=5, le=100, description="Number of resources to return"), 

17740 _user=Depends(get_current_user_with_permissions), 

17741 db: Session = Depends(get_db), 

17742): 

17743 """Get resource performance metrics (avg, min, max duration). 

17744 

17745 Args: 

17746 request: FastAPI request object 

17747 hours: Number of hours to look back (1-168) 

17748 limit: Maximum number of resources to return (5-100) 

17749 _user: Authenticated user (required by dependency) 

17750 db: Database session for permission checks. 

17751 

17752 Returns: 

17753 dict: Resource performance metrics 

17754 

17755 Raises: 

17756 HTTPException: 500 if calculation fails 

17757 """ 

17758 db = next(get_db()) 

17759 try: 

17760 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17761 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17762 

17763 # Use shared helper to compute performance grouped by the JSON attribute 

17764 resources = _get_span_entity_performance( 

17765 db=db, 

17766 cutoff_time=cutoff_time, 

17767 cutoff_time_naive=cutoff_time_naive, 

17768 span_names=["resource.read", "resources.read", "resource.fetch"], 

17769 json_key="resource.uri", 

17770 result_key="resource_uri", 

17771 limit=limit, 

17772 ) 

17773 

17774 return {"resources": resources, "time_range_hours": hours} 

17775 except Exception as e: 

17776 LOGGER.error(f"Failed to get resource performance metrics: {e}") 

17777 raise HTTPException(status_code=500, detail=str(e)) 

17778 finally: 

17779 # Ensure close() always runs even if commit() fails 

17780 try: 

17781 db.commit() # Commit read-only transaction to avoid implicit rollback 

17782 finally: 

17783 db.close() 

17784 

17785 

17786@admin_router.get("/observability/resources/errors", response_model=dict) 

17787@require_permission("admin.system_config", allow_admin_bypass=False) 

17788async def get_resources_errors( 

17789 hours: int = Query(24, description="Time range in hours"), 

17790 limit: int = Query(20, description="Maximum number of results"), 

17791 _user=Depends(get_current_user_with_permissions), 

17792 db: Session = Depends(get_db), 

17793): 

17794 """Get resource error rates. 

17795 

17796 Args: 

17797 hours: Time range in hours to analyze 

17798 limit: Maximum number of resources to return 

17799 _user: Authenticated user (required by dependency) 

17800 db: Database session for permission checks. 

17801 

17802 Returns: 

17803 dict: Resource error statistics 

17804 """ 

17805 db = next(get_db()) 

17806 try: 

17807 cutoff_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

17808 cutoff_time_naive = cutoff_time.replace(tzinfo=None) 

17809 dialect_name = db.get_bind().dialect.name 

17810 

17811 # Get all resource spans with their status 

17812 # Create expression once and reuse to avoid PostgreSQL GROUP BY errors 

17813 resource_uri_expr = extract_json_field(ObservabilitySpan.attributes, '$."resource.uri"', dialect_name=dialect_name) 

17814 resource_stats = ( 

17815 db.query( 

17816 resource_uri_expr.label("resource_uri"), 

17817 func.count().label("total_count"), # pylint: disable=not-callable 

17818 func.sum(case((ObservabilitySpan.status == "error", 1), else_=0)).label("error_count"), 

17819 ) 

17820 .filter( 

17821 ObservabilitySpan.name.in_(["resource.read", "resources.read", "resource.fetch"]), 

17822 ObservabilitySpan.start_time >= cutoff_time_naive, 

17823 resource_uri_expr.isnot(None), 

17824 ) 

17825 .group_by(resource_uri_expr) 

17826 .all() 

17827 ) 

17828 

17829 resources_data = [] 

17830 for stat in resource_stats: 

17831 total = stat.total_count 

17832 errors = stat.error_count or 0 

17833 error_rate = round((errors / total * 100), 2) if total > 0 else 0 

17834 

17835 resources_data.append({"resource_uri": stat.resource_uri, "total_count": total, "error_count": errors, "error_rate": error_rate}) 

17836 

17837 # Sort by error rate descending 

17838 resources_data.sort(key=lambda x: x["error_rate"], reverse=True) 

17839 resources_data = resources_data[:limit] 

17840 

17841 return {"resources": resources_data, "time_range_hours": hours} 

17842 finally: 

17843 # Ensure close() always runs even if commit() fails 

17844 try: 

17845 db.commit() # Commit read-only transaction to avoid implicit rollback 

17846 finally: 

17847 db.close() 

17848 

17849 

17850@admin_router.get("/observability/resources/partial", response_class=HTMLResponse) 

17851@require_permission("admin.system_config", allow_admin_bypass=False) 

17852async def get_resources_partial( 

17853 request: Request, 

17854 _user=Depends(get_current_user_with_permissions), 

17855 _db: Session = Depends(get_db), 

17856): 

17857 """Render the resource fetch metrics dashboard HTML partial. 

17858 

17859 Args: 

17860 request: FastAPI request object 

17861 _user: Authenticated user (required by dependency) 

17862 _db: Database session for permission checks. 

17863 

17864 Returns: 

17865 HTMLResponse: Rendered resource metrics dashboard partial 

17866 """ 

17867 root_path = request.scope.get("root_path", "") 

17868 return request.app.state.templates.TemplateResponse( 

17869 request, 

17870 "observability_resources.html", 

17871 { 

17872 "request": request, 

17873 "root_path": root_path, 

17874 }, 

17875 ) 

17876 

17877 

17878# =================================== 

17879# Performance Monitoring Endpoints 

17880# =================================== 

17881 

17882 

17883@admin_router.get("/performance/stats", response_class=HTMLResponse) 

17884@require_permission("admin.system_config", allow_admin_bypass=False) 

17885async def get_performance_stats( 

17886 request: Request, 

17887 db: Session = Depends(get_db), 

17888 _user=Depends(get_current_user_with_permissions), 

17889): 

17890 """Get comprehensive performance metrics for the dashboard. 

17891 

17892 Returns either an HTML partial for HTMX requests or JSON for API requests. 

17893 Includes system metrics, request metrics, worker status, and cache stats. 

17894 

17895 Args: 

17896 request: FastAPI request object 

17897 db: Database session dependency 

17898 _user: Authenticated user (required by dependency) 

17899 

17900 Returns: 

17901 HTMLResponse or JSONResponse: Performance dashboard data 

17902 

17903 Raises: 

17904 HTTPException: 404 if performance tracking is disabled, 500 on retrieval error 

17905 """ 

17906 if not settings.mcpgateway_performance_tracking: 

17907 if request.headers.get("hx-request"): 

17908 return HTMLResponse(content='<div class="text-center py-8 text-gray-500">Performance tracking is disabled. Enable with MCPGATEWAY_PERFORMANCE_TRACKING=true</div>') 

17909 raise HTTPException(status_code=404, detail="Performance monitoring is disabled") 

17910 

17911 try: 

17912 service = get_performance_service(db) 

17913 dashboard = await service.get_dashboard() 

17914 

17915 # Convert to dict for template 

17916 dashboard_data = dashboard.model_dump() 

17917 

17918 # Format datetime fields for display 

17919 if dashboard_data.get("timestamp"): 

17920 dashboard_data["timestamp"] = dashboard_data["timestamp"].isoformat() 

17921 if dashboard_data.get("system", {}).get("boot_time"): 

17922 dashboard_data["system"]["boot_time"] = dashboard_data["system"]["boot_time"].isoformat() 

17923 for worker in dashboard_data.get("workers", []): 

17924 if worker.get("create_time"): 

17925 worker["create_time"] = worker["create_time"].isoformat() 

17926 

17927 if request.headers.get("hx-request"): 

17928 root_path = request.scope.get("root_path", "") 

17929 return request.app.state.templates.TemplateResponse( 

17930 request, 

17931 "performance_partial.html", 

17932 { 

17933 "request": request, 

17934 "dashboard": dashboard_data, 

17935 "root_path": root_path, 

17936 }, 

17937 ) 

17938 

17939 return ORJSONResponse(content=dashboard_data) 

17940 

17941 except Exception as e: 

17942 LOGGER.error(f"Performance metrics retrieval failed: {str(e)}", exc_info=True) 

17943 raise HTTPException(status_code=500, detail=f"Failed to retrieve performance metrics: {str(e)}") 

17944 

17945 

17946@admin_router.get("/performance/system") 

17947@require_permission("admin.system_config", allow_admin_bypass=False) 

17948async def get_performance_system( 

17949 db: Session = Depends(get_db), 

17950 _user=Depends(get_current_user_with_permissions), 

17951): 

17952 """Get current system resource metrics. 

17953 

17954 Args: 

17955 db: Database session dependency 

17956 _user: Authenticated user (required by dependency) 

17957 

17958 Returns: 

17959 JSONResponse: System metrics (CPU, memory, disk, network) 

17960 

17961 Raises: 

17962 HTTPException: 404 if performance tracking is disabled 

17963 """ 

17964 if not settings.mcpgateway_performance_tracking: 

17965 raise HTTPException(status_code=404, detail="Performance tracking is disabled") 

17966 

17967 service = get_performance_service(db) 

17968 metrics = service.get_system_metrics() 

17969 return metrics.model_dump() 

17970 

17971 

17972@admin_router.get("/performance/workers") 

17973@require_permission("admin.system_config", allow_admin_bypass=False) 

17974async def get_performance_workers( 

17975 db: Session = Depends(get_db), 

17976 _user=Depends(get_current_user_with_permissions), 

17977): 

17978 """Get metrics for all worker processes. 

17979 

17980 Args: 

17981 db: Database session dependency 

17982 _user: Authenticated user (required by dependency) 

17983 

17984 Returns: 

17985 JSONResponse: List of worker metrics 

17986 

17987 Raises: 

17988 HTTPException: 404 if performance tracking is disabled 

17989 """ 

17990 if not settings.mcpgateway_performance_tracking: 

17991 raise HTTPException(status_code=404, detail="Performance tracking is disabled") 

17992 

17993 service = get_performance_service(db) 

17994 workers = service.get_worker_metrics() 

17995 return [w.model_dump() for w in workers] 

17996 

17997 

17998@admin_router.get("/performance/requests") 

17999@require_permission("admin.system_config", allow_admin_bypass=False) 

18000async def get_performance_requests( 

18001 db: Session = Depends(get_db), 

18002 _user=Depends(get_current_user_with_permissions), 

18003): 

18004 """Get HTTP request performance metrics. 

18005 

18006 Args: 

18007 db: Database session dependency 

18008 _user: Authenticated user (required by dependency) 

18009 

18010 Returns: 

18011 JSONResponse: Request metrics from Prometheus 

18012 

18013 Raises: 

18014 HTTPException: 404 if performance tracking is disabled 

18015 """ 

18016 if not settings.mcpgateway_performance_tracking: 

18017 raise HTTPException(status_code=404, detail="Performance tracking is disabled") 

18018 

18019 service = get_performance_service(db) 

18020 metrics = service.get_request_metrics() 

18021 return metrics.model_dump() 

18022 

18023 

18024@admin_router.get("/performance/cache") 

18025@require_permission("admin.system_config", allow_admin_bypass=False) 

18026async def get_performance_cache( 

18027 db: Session = Depends(get_db), 

18028 _user=Depends(get_current_user_with_permissions), 

18029): 

18030 """Get Redis cache metrics. 

18031 

18032 Args: 

18033 db: Database session dependency 

18034 _user: Authenticated user (required by dependency) 

18035 

18036 Returns: 

18037 JSONResponse: Redis cache metrics 

18038 

18039 Raises: 

18040 HTTPException: 404 if performance tracking is disabled 

18041 """ 

18042 if not settings.mcpgateway_performance_tracking: 

18043 raise HTTPException(status_code=404, detail="Performance tracking is disabled") 

18044 

18045 service = get_performance_service(db) 

18046 metrics = await service.get_cache_metrics() 

18047 return metrics.model_dump() 

18048 

18049 

18050@admin_router.get("/performance/history") 

18051@require_permission("admin.system_config", allow_admin_bypass=False) 

18052async def get_performance_history( 

18053 period_type: str = Query("hourly", description="Aggregation period: hourly or daily"), 

18054 hours: int = Query(24, ge=1, le=168, description="Number of hours to look back"), 

18055 db: Session = Depends(get_db), 

18056 _user=Depends(get_current_user_with_permissions), 

18057): 

18058 """Get historical performance aggregates. 

18059 

18060 Args: 

18061 period_type: Aggregation type (hourly, daily) 

18062 hours: Hours of history to retrieve 

18063 db: Database session dependency 

18064 _user: Authenticated user (required by dependency) 

18065 

18066 Returns: 

18067 JSONResponse: Historical performance aggregates 

18068 

18069 Raises: 

18070 HTTPException: 404 if performance tracking is disabled 

18071 """ 

18072 if not settings.mcpgateway_performance_tracking: 

18073 raise HTTPException(status_code=404, detail="Performance tracking is disabled") 

18074 

18075 service = get_performance_service(db) 

18076 start_time = datetime.now(timezone.utc) - timedelta(hours=hours) 

18077 

18078 history = await service.get_history( 

18079 db=db, 

18080 period_type=period_type, 

18081 start_time=start_time, 

18082 ) 

18083 

18084 return history.model_dump()